@meiti/render 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MEITI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # @meiti/render
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Render testing built for AI agents, not for people.
4
+
5
+ Today an agent tests a screen like a person would: take a screenshot, send it to a vision model, guess where to click, take another screenshot. It is slow, expensive and fragile. `@meiti/render` gives the agent what it actually needs:
6
+
7
+ - **A map of the screen instead of a picture.** Every element that matters, with its role, name, text, value, state and position, as text. It takes a few milliseconds and any model can read it.
8
+ - **Actions by intent instead of coordinates.** `fill "Email" with ...`, `click "Save"`. The element is found by its role, label or visible text. If it is not there, the answer says what is.
9
+ - **Seeing by comparing maps.** Act, scan, compare with the previous map: what appeared, what disappeared, what changed, what moved. "Nothing changed" is information too: a button that does nothing.
10
+ - **What a screenshot never shows.** The requests sent to the server and their status, page errors, native dialogs.
11
+ - **Visual issues measured exactly, no vision model needed.** Horizontal overflow, cut text, covered or off-screen elements, small touch targets, low contrast, broken images, buttons without a name.
12
+ - **Before and after.** Compare two versions of an app (home page plus up to 3 linked pages, phone and desktop): worse, better or the same.
13
+
14
+ It works with apps that render inside same-origin iframes and waits until the screen is stable (two identical maps in a row and nothing loading) before reading it.
15
+
16
+ ## Use it from an agent (MCP)
17
+
18
+ ```bash
19
+ npx -y @meiti/render
20
+ ```
21
+
22
+ Add it to any MCP client as a stdio server with the command `npx -y @meiti/render`. It needs Node 18 or newer and a Chrome: the one puppeteer downloads, otherwise the Chrome installed on the machine, or the executable you set in `MEITI_RENDER_CHROME`.
23
+
24
+ Tools:
25
+
26
+ | Tool | What it does |
27
+ |---|---|
28
+ | `render_open` | Opens a URL (any size: 375 for a phone) and returns the map and its visual issues. |
29
+ | `render_act` | One action by intent (`click`, `fill`, `select`, `check`, `key`, `goto`) and what changed. |
30
+ | `render_expect` | Checks what you expect: yes or no, with the exact proof. |
31
+ | `render_scan` | Scans the current screen again. |
32
+ | `render_compare` | Compares two versions of the same app. |
33
+ | `render_close` | Closes a session. |
34
+
35
+ Example of what the agent reads after `render_act {"type": "click", "target": "Add"}`:
36
+
37
+ ```
38
+ Acted on button:Add in 858 ms.
39
+ What changed: appeared 1: listitem:Milk; moved 4
40
+ Requests:
41
+ - POST http://localhost:3000/api/items → 201
42
+ Page errors: none
43
+ ```
44
+
45
+ ## Use it from code
46
+
47
+ ```js
48
+ import puppeteer from 'puppeteer';
49
+ import { SesionRender } from '@meiti/render';
50
+
51
+ const browser = await puppeteer.launch();
52
+ const session = await SesionRender.abrir(browser, 'http://localhost:3000', { ancho: 375, alto: 812 });
53
+ await session.hacer({ tipo: 'llenar', objetivo: 'Email', valor: 'ana@example.com' });
54
+ const result = await session.hacer({ tipo: 'clic', objetivo: 'Save' });
55
+ console.log(result.cambio.resumen);
56
+ console.log(session.verificar([{ pedido: { metodo: 'POST', url_contiene: '/api/users', status: 201 } }]));
57
+ ```
58
+
59
+ ## Verified by Healer
60
+
61
+ `@meiti/render` is a MEITI tool, verified by [Healer](https://meiti.dev/healer) with exams that have known answers, run on every version:
62
+
63
+ | Exam | Result |
64
+ |---|---|
65
+ | Visual issues (one planted issue per page, plus clean pages) | 11 of 11, 0 false alarms, ~4 ms per scan |
66
+ | Actions and expectations (JavaScript error, silent 500, missing button) | 10 of 10 |
67
+ | Apps rendered in iframes, loading states, native dialogs | 7 of 7 |
68
+ | Before and after (worse, better, same) | 3 of 3 |
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,160 @@
1
+ // 🛰️ Escáner de render: corre DENTRO de la página (se inyecta como texto, ver escaner.ts; el código que pasa por tsx se
2
+ // rompe al inyectarlo). Devuelve el MAPA de la pantalla: los parámetros renderizados de cada elemento que importa (qué es,
3
+ // qué dice, en qué estado está, dónde está y cómo se ve), más las fallas visuales que se pueden medir exacto.
4
+ // JavaScript plano a propósito: nada de TypeScript ni de sintaxis que un navegador viejo no entienda.
5
+ (function escanearPantalla() {
6
+ var TOPE = 800;
7
+ var vw = window.innerWidth, vh = window.innerHeight;
8
+ var doc = document.documentElement;
9
+ var elementos = [];
10
+ var vistos = Object.create(null);
11
+ var fallas = [];
12
+ var refs = []; // el elemento real de cada entrada del mapa, para actuar sobre él por su id (ver sesion.ts)
13
+
14
+ function rolDe(el) {
15
+ var r = el.getAttribute('role');
16
+ if (r) return r;
17
+ var t = el.tagName.toLowerCase();
18
+ if (t === 'a' && el.hasAttribute('href')) return 'link';
19
+ if (t === 'button') return 'button';
20
+ if (t === 'input') {
21
+ var tipo = (el.getAttribute('type') || 'text').toLowerCase();
22
+ if (tipo === 'checkbox') return 'checkbox';
23
+ if (tipo === 'radio') return 'radio';
24
+ if (tipo === 'submit' || tipo === 'button' || tipo === 'reset') return 'button';
25
+ if (tipo === 'hidden') return null;
26
+ return 'textbox';
27
+ }
28
+ if (t === 'textarea') return 'textbox';
29
+ if (t === 'select') return 'combobox';
30
+ if (t === 'img') return 'img';
31
+ if (/^h[1-6]$/.test(t)) return 'heading';
32
+ if (t === 'li') return 'listitem';
33
+ if (t === 'tr') return 'row';
34
+ if (t === 'nav') return 'navigation';
35
+ if (t === 'dialog') return 'dialog';
36
+ if (t === 'label') return 'label';
37
+ return null;
38
+ }
39
+ function textoPropio(el) {
40
+ var s = '';
41
+ for (var i = 0; i < el.childNodes.length; i++) if (el.childNodes[i].nodeType === 3) s += el.childNodes[i].nodeValue;
42
+ return s.replace(/\s+/g, ' ').trim();
43
+ }
44
+ function textoDe(el) { return (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim(); }
45
+ function nombreDe(el, rol) {
46
+ var a = el.getAttribute('aria-label');
47
+ if (a) return a.trim();
48
+ var lb = el.getAttribute('aria-labelledby');
49
+ if (lb) { var ref = document.getElementById(lb); if (ref) return textoDe(ref); }
50
+ if (el.id) { var l = document.querySelector('label[for="' + el.id.replace(/"/g, '\\"') + '"]'); if (l) return textoDe(l); }
51
+ var padreLabel = el.closest && el.closest('label');
52
+ if (padreLabel && padreLabel !== el) return textoDe(padreLabel);
53
+ if (rol === 'img') return (el.getAttribute('alt') || '').trim();
54
+ if (rol === 'textbox' || rol === 'combobox') return (el.getAttribute('placeholder') || el.getAttribute('name') || '').trim();
55
+ var t = el.getAttribute('title');
56
+ var tx = textoDe(el);
57
+ return (tx || t || '').slice(0, 120);
58
+ }
59
+ function esVisible(el, cs, caja) {
60
+ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
61
+ if (caja.width < 1 || caja.height < 1) return false;
62
+ return true;
63
+ }
64
+ function esClicable(el, rol, cs) {
65
+ if (['button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'tab', 'menuitem', 'switch', 'option'].indexOf(rol) >= 0) return true;
66
+ return cs.cursor === 'pointer' && !!el.onclick;
67
+ }
68
+ // Color efectivo del fondo: el primer ancestro con fondo no transparente.
69
+ function rgba(c) { var m = /rgba?\(([^)]+)\)/.exec(c || ''); if (!m) return null; var p = m[1].split(',').map(function (x) { return parseFloat(x); }); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; }
70
+ function fondoDe(el) {
71
+ for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
72
+ var c = rgba(getComputedStyle(n).backgroundColor);
73
+ if (c && c.a > 0.5) return c;
74
+ if (getComputedStyle(n).backgroundImage !== 'none') return null; // sobre una imagen no se puede juzgar
75
+ }
76
+ return { r: 255, g: 255, b: 255, a: 1 };
77
+ }
78
+ function luminancia(c) { var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); }
79
+ function contraste(a, b) { var l1 = luminancia(a), l2 = luminancia(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); }
80
+
81
+ var todos = document.body ? document.body.querySelectorAll('*') : [];
82
+ for (var i = 0; i < todos.length && elementos.length < TOPE; i++) {
83
+ var el = todos[i];
84
+ var tag = el.tagName.toLowerCase();
85
+ if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
86
+ var rol = rolDe(el);
87
+ var propio = textoPropio(el);
88
+ if (!rol && !propio) continue;
89
+ var cs = getComputedStyle(el);
90
+ var caja = el.getBoundingClientRect();
91
+ var visible = esVisible(el, cs, caja);
92
+ if (!visible && !rol) continue;
93
+ var nombre = rol ? nombreDe(el, rol) : '';
94
+ var texto = (propio || (rol ? textoDe(el) : '')).slice(0, 200);
95
+ var clave = (rol || 'texto') + ':' + (nombre || texto).slice(0, 60);
96
+ vistos[clave] = (vistos[clave] || 0) + 1;
97
+ var id = vistos[clave] > 1 ? clave + '#' + vistos[clave] : clave;
98
+ var estado = {};
99
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true') estado.deshabilitado = true;
100
+ if (el.checked || el.getAttribute('aria-checked') === 'true') estado.marcado = true;
101
+ if (el.required || el.getAttribute('aria-required') === 'true') estado.requerido = true;
102
+ if ((el.validity && el.validity.valid === false) || el.getAttribute('aria-invalid') === 'true') estado.invalido = true;
103
+ if (el.getAttribute('aria-expanded')) estado.expandido = el.getAttribute('aria-expanded') === 'true';
104
+ if (document.activeElement === el) estado.enfocado = true;
105
+ var e = {
106
+ id: id, rol: rol || 'texto', nombre: nombre || undefined, texto: texto || undefined,
107
+ valor: (tag === 'input' || tag === 'textarea' || tag === 'select') && el.type !== 'password' ? String(el.value || '').slice(0, 120) : undefined,
108
+ estado: Object.keys(estado).length ? estado : undefined,
109
+ caja: { x: Math.round(caja.left), y: Math.round(caja.top + window.scrollY), w: Math.round(caja.width), h: Math.round(caja.height) },
110
+ visible: visible,
111
+ estilo: { color: cs.color, fondo: cs.backgroundColor, fuente: Math.round(parseFloat(cs.fontSize)), peso: cs.fontWeight },
112
+ };
113
+ // A dónde lleva un enlace de la misma app (para recorrer sus pantallas, ver versiones.ts).
114
+ if (rol === 'link' && el.href) { try { var u = new URL(el.href, location.href); if (u.origin === location.origin && u.pathname + u.search !== location.pathname + location.search) e.destino = u.pathname + u.search; } catch (x) { /* dirección rara */ } }
115
+ elementos.push(e);
116
+ refs.push(el);
117
+ if (!visible) continue;
118
+
119
+ var clicable = esClicable(el, rol, cs);
120
+ // Fallas que se pueden medir exacto.
121
+ if ((cs.overflow === 'hidden' || cs.overflowX === 'hidden' || cs.textOverflow === 'ellipsis') && el.scrollWidth > el.clientWidth + 1 && texto)
122
+ fallas.push({ tipo: 'texto_cortado', id: id, detalle: 'the text needs ' + el.scrollWidth + ' px and only ' + el.clientWidth + ' px are visible' });
123
+ if (clicable && (caja.right > vw + 1 || caja.left < -1))
124
+ fallas.push({ tipo: 'fuera_de_pantalla', id: id, detalle: 'it spans ' + Math.round(caja.left) + ' to ' + Math.round(caja.right) + ' px on a ' + vw + ' px wide screen' });
125
+ // Un enlace en medio de una oración no cuenta (la norma de accesibilidad lo exceptúa: no puede medir 24 px de alto sin
126
+ // romper el párrafo). Medido el 2026-11-12 en connect.html: cuatro falsas alarmas así.
127
+ var enLinea = cs.display === 'inline' && el.parentElement && textoDe(el.parentElement).length > textoDe(el).length + 10;
128
+ if (clicable && !enLinea && vw < 600 && (caja.width < 24 || caja.height < 24))
129
+ fallas.push({ tipo: 'toque_chico', id: id, detalle: Math.round(caja.width) + ' x ' + Math.round(caja.height) + ' px (at least 24 x 24 for a finger)' });
130
+ if (clicable && caja.top < vh && caja.bottom > 0) {
131
+ var cx = caja.left + caja.width / 2, cy = caja.top + caja.height / 2;
132
+ if (cx >= 0 && cx < vw && cy >= 0 && cy < vh) {
133
+ var arriba = document.elementFromPoint(cx, cy);
134
+ if (arriba && arriba !== el && !el.contains(arriba) && !arriba.contains(el))
135
+ fallas.push({ tipo: 'tapado', id: id, detalle: 'another element sits on its center: ' + (arriba.tagName.toLowerCase()) + (arriba.id ? '#' + arriba.id : '') + ' "' + textoDe(arriba).slice(0, 40) + '"' });
136
+ }
137
+ }
138
+ if ((rol === 'button' || rol === 'link') && !nombre)
139
+ fallas.push({ tipo: 'sin_nombre', id: id, detalle: 'a ' + rol + ' with no text or aria-label: neither a screen reader user nor an agent can tell what it does' });
140
+ if (tag === 'img' && el.complete && el.naturalWidth === 0)
141
+ fallas.push({ tipo: 'imagen_rota', id: id, detalle: 'it did not load: ' + (el.getAttribute('src') || '').slice(0, 80) });
142
+ if (propio) {
143
+ var color = rgba(cs.color), fondo = fondoDe(el);
144
+ if (color && fondo && color.a > 0.5) {
145
+ var tam = parseFloat(cs.fontSize), negrita = Number(cs.fontWeight) >= 700;
146
+ var grande = tam >= 24 || (negrita && tam >= 18.66);
147
+ var r = contraste(color, fondo);
148
+ if (r < (grande ? 3 : 4.5)) fallas.push({ tipo: 'contraste', id: id, detalle: 'contrast ' + r.toFixed(2) + ':1 (minimum ' + (grande ? '3' : '4.5') + ':1)' });
149
+ }
150
+ }
151
+ }
152
+ if (doc.scrollWidth > vw + 1) fallas.push({ tipo: 'desborde_horizontal', id: null, detalle: 'the page is ' + doc.scrollWidth + ' px wide on a ' + vw + ' px screen: it scrolls sideways' });
153
+
154
+ window.__renderRefs = refs;
155
+ return {
156
+ url: location.href, titulo: document.title,
157
+ pantalla: { ancho: vw, alto: vh, ancho_total: doc.scrollWidth, alto_total: doc.scrollHeight },
158
+ elementos: elementos, fallas: fallas, recortado: elementos.length >= TOPE,
159
+ };
160
+ })()
@@ -0,0 +1,94 @@
1
+ import type { Frame, Page } from 'puppeteer';
2
+ export interface ElementoMapa {
3
+ id: string;
4
+ rol: string;
5
+ nombre?: string;
6
+ texto?: string;
7
+ valor?: string;
8
+ estado?: {
9
+ deshabilitado?: boolean;
10
+ marcado?: boolean;
11
+ requerido?: boolean;
12
+ invalido?: boolean;
13
+ expandido?: boolean;
14
+ enfocado?: boolean;
15
+ };
16
+ caja: {
17
+ x: number;
18
+ y: number;
19
+ w: number;
20
+ h: number;
21
+ };
22
+ visible: boolean;
23
+ estilo: {
24
+ color: string;
25
+ fondo: string;
26
+ fuente: number;
27
+ peso: string;
28
+ };
29
+ destino?: string;
30
+ }
31
+ export type TipoFalla = 'desborde_horizontal' | 'texto_cortado' | 'fuera_de_pantalla' | 'toque_chico' | 'tapado' | 'sin_nombre' | 'imagen_rota' | 'contraste';
32
+ export interface FallaRender {
33
+ tipo: TipoFalla;
34
+ id: string | null;
35
+ detalle: string;
36
+ }
37
+ export interface MapaPantalla {
38
+ url: string;
39
+ titulo: string;
40
+ pantalla: {
41
+ ancho: number;
42
+ alto: number;
43
+ ancho_total: number;
44
+ alto_total: number;
45
+ };
46
+ elementos: ElementoMapa[];
47
+ fallas: FallaRender[];
48
+ recortado: boolean;
49
+ ms: number;
50
+ }
51
+ export declare const referenciaDe: (mapa: MapaPantalla, i: number) => {
52
+ marco: Frame;
53
+ indice: number;
54
+ } | null;
55
+ /**
56
+ * Escanea la pantalla actual de la página y devuelve su mapa.
57
+ * 🧩 [Aprendido de MEITI, ojo_optico.js, bug real del 2026-08-05/06] Cada molde de una app de MEITI se dibuja dentro de su
58
+ * propio <iframe src="sandbox-runtime.html">, uno por molde: un escaneo solo del documento principal no ve nada. Se escanea
59
+ * cada marco y se une todo en un solo mapa, con las posiciones llevadas a la pantalla real.
60
+ */
61
+ export declare function escanear(pagina: Page): Promise<MapaPantalla>;
62
+ export declare function escanearEstable(pagina: Page, opciones?: {
63
+ maxMs?: number;
64
+ pausaMs?: number;
65
+ }): Promise<MapaPantalla>;
66
+ export interface CambioElemento {
67
+ id: string;
68
+ campos: Record<string, {
69
+ antes: unknown;
70
+ despues: unknown;
71
+ }>;
72
+ }
73
+ export interface ComparacionMapas {
74
+ sin_cambios: boolean;
75
+ aparecieron: ElementoMapa[];
76
+ desaparecieron: ElementoMapa[];
77
+ cambiaron: CambioElemento[];
78
+ se_movieron: {
79
+ id: string;
80
+ dx: number;
81
+ dy: number;
82
+ }[];
83
+ fallas_nuevas: FallaRender[];
84
+ fallas_resueltas: FallaRender[];
85
+ foco: {
86
+ antes: string | null;
87
+ despues: string | null;
88
+ };
89
+ resumen: string;
90
+ }
91
+ /** Compara dos mapas: lo que cambió entre uno y otro es lo que hizo la acción del medio. */
92
+ export declare function comparar(antes: MapaPantalla, despues: MapaPantalla): ComparacionMapas;
93
+ /** El mapa en texto corto, para un modelo: una línea por elemento visible. */
94
+ export declare function mapaEnTexto(mapa: MapaPantalla, tope?: number): string;
@@ -0,0 +1,162 @@
1
+ // 🛰️ [PRUEBA DE RENDER PARA AGENTES, 2026-11-12, primera herramienta pensada para agentes, idea del usuario: "en vez de ver
2
+ // imágenes y dar clics en el navegador, algo que de verdad sirva a un agente; el mapa interactivo como un GPS y un escáner:
3
+ // acción 1, escaneo 1, mapa 1; acción 2, escaneo 2, mapa 2; comparo el mapa 1 con el 2: si todo sigue igual o se nota el
4
+ // cambio, eso es ver"].
5
+ // - Escanear: el MAPA de la pantalla son los parámetros renderizados de cada elemento (qué es, qué dice, en qué estado está,
6
+ // dónde está y cómo se ve), no una foto. Se lee en milisegundos y lo entiende cualquier modelo.
7
+ // - Comparar dos mapas: qué apareció, qué desapareció, qué cambió, qué se movió. Que no cambie nada también es información
8
+ // (un botón que no hace nada).
9
+ // - Fallas visuales medidas exacto, sin modelo de visión: desborde, texto cortado, elementos tapados, toques chicos,
10
+ // contraste, imágenes rotas, botones sin nombre.
11
+ // Primer paso: escáner, comparador y examen con respuesta conocida (benchmark/render). Después: acciones por intención,
12
+ // expectativas y la herramienta MCP en el conector de Healer.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ // El código que corre en la página: junto a este archivo (en el paquete @meiti/render) o en la carpeta de trabajo de Healer
16
+ // (en producción corre dist/ compilado y tsc no copia .js sueltos). Se lee una vez.
17
+ let codigoCache = null;
18
+ const CODIGO = () => {
19
+ if (codigoCache)
20
+ return codigoCache;
21
+ const candidatos = [new URL('./escaner-navegador.js', import.meta.url), path.resolve(process.cwd(), 'src', 'render', 'escaner-navegador.js')];
22
+ for (const c of candidatos) {
23
+ try {
24
+ codigoCache = fs.readFileSync(c, 'utf8');
25
+ return codigoCache;
26
+ }
27
+ catch { /* sigue */ }
28
+ }
29
+ throw new Error('no se encontró escaner-navegador.js');
30
+ };
31
+ // De qué marco (documento principal o iframe) y en qué lugar de su lista salió cada elemento del mapa: para actuar sobre él
32
+ // (ver sesion.ts). Va aparte del mapa porque un Frame no se puede serializar.
33
+ const referencias = new WeakMap();
34
+ export const referenciaDe = (mapa, i) => referencias.get(mapa)?.[i] ?? null;
35
+ /**
36
+ * Escanea la pantalla actual de la página y devuelve su mapa.
37
+ * 🧩 [Aprendido de MEITI, ojo_optico.js, bug real del 2026-08-05/06] Cada molde de una app de MEITI se dibuja dentro de su
38
+ * propio <iframe src="sandbox-runtime.html">, uno por molde: un escaneo solo del documento principal no ve nada. Se escanea
39
+ * cada marco y se une todo en un solo mapa, con las posiciones llevadas a la pantalla real.
40
+ */
41
+ export async function escanear(pagina) {
42
+ const inicio = Date.now();
43
+ const codigo = CODIGO();
44
+ const principal = (await pagina.mainFrame().evaluate(codigo));
45
+ const refs = principal.elementos.map((_, indice) => ({ marco: pagina.mainFrame(), indice }));
46
+ const cuenta = new Map();
47
+ const base = (id) => id.replace(/#\d+$/, '');
48
+ for (const e of principal.elementos)
49
+ cuenta.set(base(e.id), (cuenta.get(base(e.id)) ?? 0) + 1);
50
+ const scrollY = Number(await pagina.evaluate('window.scrollY').catch(() => 0)) || 0;
51
+ for (const marco of pagina.frames()) {
52
+ if (marco === pagina.mainFrame())
53
+ continue;
54
+ const caja = await (await marco.frameElement().catch(() => null))?.boundingBox().catch(() => null);
55
+ if (!caja || caja.width < 1 || caja.height < 1)
56
+ continue; // iframe oculto
57
+ let sub;
58
+ try {
59
+ sub = (await marco.evaluate(codigo));
60
+ }
61
+ catch {
62
+ continue;
63
+ } // de otro origen: no se puede leer
64
+ const renombre = new Map();
65
+ sub.elementos.forEach((e, indice) => {
66
+ const b = base(e.id);
67
+ const n = (cuenta.get(b) ?? 0) + 1;
68
+ cuenta.set(b, n);
69
+ const id = n > 1 ? `${b}#${n}` : b;
70
+ renombre.set(e.id, id);
71
+ principal.elementos.push({ ...e, id, caja: { ...e.caja, x: e.caja.x + Math.round(caja.x), y: e.caja.y + Math.round(caja.y) + scrollY } });
72
+ refs.push({ marco, indice });
73
+ });
74
+ for (const f of sub.fallas)
75
+ principal.fallas.push({ ...f, id: f.id ? renombre.get(f.id) ?? f.id : null, detalle: f.id ? f.detalle : `${f.detalle} (inside a ${Math.round(caja.width)} px frame)` });
76
+ principal.recortado ||= sub.recortado;
77
+ }
78
+ const mapa = { ...principal, ms: Date.now() - inicio };
79
+ referencias.set(mapa, refs);
80
+ return mapa;
81
+ }
82
+ // Lo que dice que una pantalla todavía está cargando (el "Preparando esta sección..." de MEITI y los de cualquier app).
83
+ const CARGANDO = /^(cargando|loading|preparando|espere|please wait)/i;
84
+ const cargando = (m) => m.elementos.some((e) => e.visible && (e.rol === 'progressbar' || CARGANDO.test(e.texto ?? '') || CARGANDO.test(e.nombre ?? '')));
85
+ /**
86
+ * 🕰️ Escaneo estable [aprendido de MEITI, ojo_optico.js: "las capturas eran pantallazos en la nada", 2026-09-15]: una página
87
+ * "cargó" antes de que sus datos lleguen. Con mapas se sabe sin adivinar un tiempo fijo: se escanea, se espera un instante
88
+ * y se vuelve a escanear; cuando dos mapas seguidos son iguales y no queda nada cargando, la pantalla está lista. Si no se
89
+ * estabiliza en maxMs, se devuelve lo último (nunca cuelga).
90
+ */
91
+ // Una copia del mapa con otro tiempo, que conserva a qué marco pertenece cada elemento.
92
+ const conMs = (m, ms) => { const r = { ...m, ms }; const refs = referencias.get(m); if (refs)
93
+ referencias.set(r, refs); return r; };
94
+ export async function escanearEstable(pagina, opciones = {}) {
95
+ const inicio = Date.now();
96
+ const maxMs = opciones.maxMs ?? 8_000, pausa = opciones.pausaMs ?? 350;
97
+ let anterior = await escanear(pagina);
98
+ while (Date.now() - inicio < maxMs) {
99
+ await new Promise((ok) => setTimeout(ok, pausa));
100
+ const actual = await escanear(pagina);
101
+ if (comparar(anterior, actual).sin_cambios && !cargando(actual))
102
+ return conMs(actual, Date.now() - inicio);
103
+ anterior = actual;
104
+ }
105
+ return conMs(anterior, Date.now() - inicio);
106
+ }
107
+ const MOVIMIENTO_MINIMO_PX = 4;
108
+ const claveFalla = (f) => `${f.tipo}|${f.id ?? ''}`;
109
+ /** Compara dos mapas: lo que cambió entre uno y otro es lo que hizo la acción del medio. */
110
+ export function comparar(antes, despues) {
111
+ const a = new Map(antes.elementos.map((e) => [e.id, e]));
112
+ const b = new Map(despues.elementos.map((e) => [e.id, e]));
113
+ const aparecieron = despues.elementos.filter((e) => !a.has(e.id) && e.visible);
114
+ const desaparecieron = antes.elementos.filter((e) => e.visible && (!b.has(e.id) || !b.get(e.id).visible));
115
+ const cambiaron = [];
116
+ const se_movieron = [];
117
+ for (const [id, x] of a) {
118
+ const y = b.get(id);
119
+ if (!y || !x.visible || !y.visible)
120
+ continue;
121
+ const campos = {};
122
+ for (const campo of ['texto', 'valor', 'nombre'])
123
+ if ((x[campo] ?? '') !== (y[campo] ?? ''))
124
+ campos[campo] = { antes: x[campo], despues: y[campo] };
125
+ // El foco se mueve con cada clic: es real, pero no es un efecto de la acción (se informa aparte, en "foco").
126
+ const sinFoco = (e) => { const { enfocado: _f, ...resto } = e ?? {}; return resto; };
127
+ if (JSON.stringify(sinFoco(x.estado)) !== JSON.stringify(sinFoco(y.estado)))
128
+ campos.estado = { antes: sinFoco(x.estado), despues: sinFoco(y.estado) };
129
+ for (const campo of ['color', 'fondo', 'fuente'])
130
+ if (x.estilo[campo] !== y.estilo[campo])
131
+ campos[campo] = { antes: x.estilo[campo], despues: y.estilo[campo] };
132
+ if (Object.keys(campos).length)
133
+ cambiaron.push({ id, campos });
134
+ const dx = y.caja.x - x.caja.x, dy = y.caja.y - x.caja.y;
135
+ if (Math.abs(dx) >= MOVIMIENTO_MINIMO_PX || Math.abs(dy) >= MOVIMIENTO_MINIMO_PX)
136
+ se_movieron.push({ id, dx, dy });
137
+ }
138
+ const fa = new Set(antes.fallas.map(claveFalla)), fb = new Set(despues.fallas.map(claveFalla));
139
+ const fallas_nuevas = despues.fallas.filter((f) => !fa.has(claveFalla(f)));
140
+ const fallas_resueltas = antes.fallas.filter((f) => !fb.has(claveFalla(f)));
141
+ const enfocado = (m) => m.elementos.find((e) => e.estado?.enfocado)?.id ?? null;
142
+ const foco = { antes: enfocado(antes), despues: enfocado(despues) };
143
+ const sin_cambios = !aparecieron.length && !desaparecieron.length && !cambiaron.length && !se_movieron.length && antes.url === despues.url;
144
+ const partes = [
145
+ antes.url !== despues.url ? `went to another page (${despues.url})` : '',
146
+ aparecieron.length ? `appeared ${aparecieron.length}: ${aparecieron.slice(0, 5).map((e) => e.id).join(', ')}` : '',
147
+ desaparecieron.length ? `disappeared ${desaparecieron.length}: ${desaparecieron.slice(0, 5).map((e) => e.id).join(', ')}` : '',
148
+ cambiaron.length ? `changed ${cambiaron.length}: ${cambiaron.slice(0, 5).map((c) => `${c.id} (${Object.keys(c.campos).join(', ')})`).join(', ')}` : '',
149
+ se_movieron.length ? `moved ${se_movieron.length}` : '',
150
+ fallas_nuevas.length ? `new visual issues: ${fallas_nuevas.map((f) => f.tipo).join(', ')}` : '',
151
+ ].filter(Boolean);
152
+ return { sin_cambios, aparecieron, desaparecieron, cambiaron, se_movieron, fallas_nuevas, fallas_resueltas, foco, resumen: sin_cambios ? 'nothing changed on the screen' : partes.join('; ') };
153
+ }
154
+ /** El mapa en texto corto, para un modelo: una línea por elemento visible. */
155
+ export function mapaEnTexto(mapa, tope = 150) {
156
+ const lineas = mapa.elementos.filter((e) => e.visible).slice(0, tope).map((e) => {
157
+ const estado = e.estado ? ' [' + Object.entries(e.estado).filter(([, v]) => v).map(([k]) => k).join(', ') + ']' : '';
158
+ const valor = e.valor !== undefined ? ` = "${e.valor}"` : '';
159
+ return `${e.rol}${e.nombre ? ` "${e.nombre}"` : e.texto ? ` "${e.texto.slice(0, 80)}"` : ''}${valor}${estado} @${e.caja.x},${e.caja.y} ${e.caja.w}x${e.caja.h}`;
160
+ });
161
+ return [`${mapa.titulo || mapa.url} (${mapa.pantalla.ancho}x${mapa.pantalla.alto})`, ...lineas, ...mapa.fallas.map((f) => `ISSUE ${f.tipo}${f.id ? ` at ${f.id}` : ''}: ${f.detalle}`)].join('\n');
162
+ }
@@ -0,0 +1,3 @@
1
+ export { escanear, escanearEstable, comparar, mapaEnTexto, type MapaPantalla, type ElementoMapa, type FallaRender, type TipoFalla, type ComparacionMapas } from './escaner.js';
2
+ export { SesionRender, resolver, type Accion, type Expectativa, type ResultadoAccion, type ResultadoExpectativa, type PedidoRed } from './sesion.js';
3
+ export { rutasDeLaApp, recorrerApp, compararVersiones, PANTALLAS, type RecorridoApp, type ResultadoVisual, type RegresionVisual } from './recorrido.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { escanear, escanearEstable, comparar, mapaEnTexto } from './escaner.js';
2
+ export { SesionRender, resolver } from './sesion.js';
3
+ export { rutasDeLaApp, recorrerApp, compararVersiones, PANTALLAS } from './recorrido.js';
@@ -0,0 +1,37 @@
1
+ import type { Page } from 'puppeteer';
2
+ import { type MapaPantalla } from './escaner.js';
3
+ export declare const PANTALLAS: readonly [{
4
+ readonly nombre: 'phone';
5
+ readonly ancho: 375;
6
+ readonly alto: 812;
7
+ }, {
8
+ readonly nombre: 'desktop';
9
+ readonly ancho: 1280;
10
+ readonly alto: 800;
11
+ }];
12
+ export interface RecorridoApp {
13
+ rutas: string[];
14
+ mapas: Record<string, MapaPantalla>;
15
+ no_abrieron: string[];
16
+ }
17
+ /** Las rutas a recorrer: la de inicio y hasta MAX_RUTAS - 1 de las que llevan sus enlaces (siempre las mismas, en orden). */
18
+ export declare function rutasDeLaApp(pagina: Page, base: string): Promise<string[]>;
19
+ /** Recorre las rutas en cada pantalla y saca un mapa de cada una. */
20
+ export declare function recorrerApp(pagina: Page, base: string, rutas: string[]): Promise<RecorridoApp>;
21
+ export interface RegresionVisual {
22
+ pantalla: string;
23
+ tipo: 'falla_nueva' | 'desaparecio' | 'no_abre';
24
+ detalle: string;
25
+ }
26
+ export interface ResultadoVisual {
27
+ veredicto: 'peor' | 'igual' | 'mejor' | 'sin_datos';
28
+ pantallas: number;
29
+ fallas_antes: number;
30
+ fallas_despues: number;
31
+ regresiones: RegresionVisual[];
32
+ mejoras: string[];
33
+ resumen: string;
34
+ ms?: number;
35
+ }
36
+ /** Compara el recorrido de la versión original con el de la reparada, pantalla por pantalla. */
37
+ export declare function compararVersiones(antes: RecorridoApp, despues: RecorridoApp): ResultadoVisual;
@@ -0,0 +1,85 @@
1
+ import { escanearEstable as escanear, comparar } from './escaner.js';
2
+ export const PANTALLAS = [
3
+ { nombre: 'phone', ancho: 375, alto: 812 },
4
+ { nombre: 'desktop', ancho: 1280, alto: 800 },
5
+ ];
6
+ const MAX_RUTAS = 4;
7
+ const INTERACTIVOS = new Set(['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab', 'menuitem', 'switch']);
8
+ /** Las rutas a recorrer: la de inicio y hasta MAX_RUTAS - 1 de las que llevan sus enlaces (siempre las mismas, en orden). */
9
+ export async function rutasDeLaApp(pagina, base) {
10
+ await pagina.setViewport({ width: 1280, height: 800 });
11
+ await pagina.goto(base, { waitUntil: 'networkidle2', timeout: 30_000 });
12
+ const mapa = await escanear(pagina);
13
+ const destinos = [...new Set(mapa.elementos.filter((e) => e.destino).map((e) => e.destino))].sort();
14
+ return ['/', ...destinos.filter((d) => d !== '/')].slice(0, MAX_RUTAS);
15
+ }
16
+ /** Recorre las rutas en cada pantalla y saca un mapa de cada una. */
17
+ export async function recorrerApp(pagina, base, rutas) {
18
+ const mapas = {};
19
+ const no_abrieron = [];
20
+ for (const ruta of rutas) {
21
+ for (const p of PANTALLAS) {
22
+ const clave = `${ruta} @${p.nombre}`;
23
+ // 🔁 [2026-11-12, primera corrida real: "/ @phone ahora abre" en una app que abría igual] Justo después de arrancar el
24
+ // servidor, la primera navegación puede tropezar: una pantalla solo cuenta como que no abre si falla dos veces.
25
+ let ultimoError = '';
26
+ for (let intento = 1; intento <= 2 && !mapas[clave]; intento++) {
27
+ try {
28
+ await pagina.setViewport({ width: p.ancho, height: p.alto });
29
+ const r = await pagina.goto(new URL(ruta, base).href, { waitUntil: 'networkidle2', timeout: 30_000 });
30
+ if (r && r.status() >= 400) {
31
+ ultimoError = `answered ${r.status()}`;
32
+ if (intento === 1)
33
+ await new Promise((ok) => setTimeout(ok, 1_500));
34
+ continue;
35
+ }
36
+ mapas[clave] = await escanear(pagina);
37
+ }
38
+ catch (error) {
39
+ ultimoError = String(error?.message || error).slice(0, 80);
40
+ if (intento === 1)
41
+ await new Promise((ok) => setTimeout(ok, 1_500));
42
+ }
43
+ }
44
+ if (!mapas[clave])
45
+ no_abrieron.push(`${clave} (${ultimoError})`);
46
+ }
47
+ }
48
+ return { rutas, mapas, no_abrieron };
49
+ }
50
+ const describirFalla = (f) => `${f.tipo}${f.id ? ` en ${f.id}` : ''}: ${f.detalle}`;
51
+ /** Compara el recorrido de la versión original con el de la reparada, pantalla por pantalla. */
52
+ export function compararVersiones(antes, despues) {
53
+ const regresiones = [];
54
+ const mejoras = [];
55
+ let pantallas = 0, fallasAntes = 0, fallasDespues = 0;
56
+ for (const [clave, a] of Object.entries(antes.mapas)) {
57
+ const b = despues.mapas[clave];
58
+ if (!b) {
59
+ regresiones.push({ pantalla: clave, tipo: 'no_abre', detalle: despues.no_abrieron.find((x) => x.startsWith(clave)) ?? 'it does not open in the new version' });
60
+ continue;
61
+ }
62
+ pantallas++;
63
+ fallasAntes += a.fallas.length;
64
+ fallasDespues += b.fallas.length;
65
+ const c = comparar(a, b);
66
+ for (const f of c.fallas_nuevas)
67
+ regresiones.push({ pantalla: clave, tipo: 'falla_nueva', detalle: describirFalla(f) });
68
+ for (const e of c.desaparecieron.filter((x) => INTERACTIVOS.has(x.rol)))
69
+ regresiones.push({ pantalla: clave, tipo: 'desaparecio', detalle: `${e.rol} "${e.nombre ?? e.texto ?? e.id}" is gone` });
70
+ for (const f of c.fallas_resueltas)
71
+ mejoras.push(`${clave}: ${describirFalla(f)}`);
72
+ }
73
+ // Una pantalla que no abría en la original y ahora sí también es una mejora.
74
+ for (const x of antes.no_abrieron) {
75
+ const clave = x.split(' (')[0];
76
+ if (despues.mapas[clave])
77
+ mejoras.push(`${clave}: it opens now`);
78
+ }
79
+ const veredicto = !pantallas && !regresiones.length ? 'sin_datos' : regresiones.length ? 'peor' : mejoras.length ? 'mejor' : 'igual';
80
+ const resumen = veredicto === 'sin_datos' ? 'no screen could be opened'
81
+ : veredicto === 'peor' ? `the new version is worse in ${regresiones.length} point(s): ${regresiones.slice(0, 3).map((r) => `${r.pantalla}, ${r.detalle}`).join('; ')}`
82
+ : veredicto === 'mejor' ? `the new version is better: ${mejoras.slice(0, 3).join('; ')}`
83
+ : `the same in the ${pantallas} screens checked`;
84
+ return { veredicto, pantallas, fallas_antes: fallasAntes, fallas_despues: fallasDespues, regresiones, mejoras, resumen };
85
+ }
@@ -0,0 +1,92 @@
1
+ import type { Browser, Page } from 'puppeteer';
2
+ import { type MapaPantalla, type ComparacionMapas } from './escaner.js';
3
+ export type Accion = {
4
+ tipo: 'clic';
5
+ objetivo: string;
6
+ } | {
7
+ tipo: 'llenar';
8
+ objetivo: string;
9
+ valor: string;
10
+ } | {
11
+ tipo: 'elegir';
12
+ objetivo: string;
13
+ valor: string;
14
+ } | {
15
+ tipo: 'marcar';
16
+ objetivo: string;
17
+ valor?: boolean;
18
+ } | {
19
+ tipo: 'tecla';
20
+ tecla: string;
21
+ } | {
22
+ tipo: 'ir';
23
+ url: string;
24
+ };
25
+ export interface PedidoRed {
26
+ metodo: string;
27
+ url: string;
28
+ status: number;
29
+ }
30
+ export interface ResultadoAccion {
31
+ accion: Accion;
32
+ objetivo: string | null;
33
+ ok: boolean;
34
+ error?: string;
35
+ cambio: ComparacionMapas | null;
36
+ red: PedidoRed[];
37
+ errores_pagina: string[];
38
+ dialogos: string[];
39
+ ms: number;
40
+ }
41
+ export type Expectativa = {
42
+ existe: string;
43
+ } | {
44
+ no_existe: string;
45
+ } | {
46
+ texto: string;
47
+ contiene: string;
48
+ } | {
49
+ aparecio: string;
50
+ } | {
51
+ desaparecio: string;
52
+ } | {
53
+ sin_cambios: boolean;
54
+ } | {
55
+ sin_fallas: true | string[];
56
+ } | {
57
+ sin_errores: true;
58
+ } | {
59
+ pedido: {
60
+ metodo?: string;
61
+ url_contiene: string;
62
+ status?: number;
63
+ };
64
+ };
65
+ export interface ResultadoExpectativa {
66
+ expectativa: Expectativa;
67
+ cumple: boolean;
68
+ prueba: string;
69
+ }
70
+ /** El índice en el mapa del elemento al que se refiere el agente (-1 si no está). */
71
+ export declare function resolver(mapa: MapaPantalla, objetivo: string): number;
72
+ export declare class SesionRender {
73
+ readonly pagina: Page;
74
+ mapa: MapaPantalla | null;
75
+ ultima: ResultadoAccion | null;
76
+ private red;
77
+ private errores;
78
+ private dialogos;
79
+ private constructor();
80
+ static abrir(navegador: Browser, url: string, pantalla?: {
81
+ ancho: number;
82
+ alto: number;
83
+ }, preparar?: (pagina: Page) => Promise<void>): Promise<SesionRender>;
84
+ escanear(): Promise<MapaPantalla>;
85
+ private marcoDe;
86
+ private elemento;
87
+ /** Acción → escaneo → mapa nuevo → comparación con el anterior. */
88
+ hacer(accion: Accion): Promise<ResultadoAccion>;
89
+ /** El agente dice qué espera; la sesión contesta sí o no, con la prueba. */
90
+ verificar(expectativas: Expectativa[]): ResultadoExpectativa[];
91
+ cerrar(): Promise<void>;
92
+ }
package/dist/sesion.js ADDED
@@ -0,0 +1,199 @@
1
+ import { escanear, escanearEstable, comparar, referenciaDe } from './escaner.js';
2
+ const normal = (s) => (s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
3
+ const INTERACTIVOS = new Set(['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab', 'menuitem', 'switch', 'option']);
4
+ const describir = (e) => `${e.rol}${e.nombre ? ` "${e.nombre}"` : e.texto ? ` "${e.texto.slice(0, 60)}"` : ''}${e.valor !== undefined ? ` = "${e.valor}"` : ''} @${e.caja.x},${e.caja.y}`;
5
+ /** El índice en el mapa del elemento al que se refiere el agente (-1 si no está). */
6
+ export function resolver(mapa, objetivo) {
7
+ const els = mapa.elementos;
8
+ const exacto = els.findIndex((e) => e.id === objetivo);
9
+ if (exacto >= 0)
10
+ return exacto;
11
+ // 🩹 [2026-11-13, examen de uso real v2: el modelo copió la línea del mapa, 'combobox "Categoría"', y no se encontraba] Se acepta
12
+ // el objetivo como lo escribe un modelo: "rol:nombre", 'rol "nombre"' (con o sin la comilla de cierre) y lo que sobra después de
13
+ // un " | " o un " = " (restos del formato de la respuesta) se descarta.
14
+ const limpio = objetivo.split(/\s+[|=]\s*/)[0].trim();
15
+ let rol = null, texto = limpio;
16
+ const m = /^([a-z]+):(.+)$/.exec(limpio) || /^([a-z]+)\s+["'“‘](.+?)["'”’]?$/.exec(limpio);
17
+ if (m && els.some((e) => e.rol === m[1])) {
18
+ rol = m[1];
19
+ texto = m[2].trim();
20
+ }
21
+ if (texto !== objetivo) {
22
+ const exacto2 = els.findIndex((e) => e.id === (rol ? `${rol}:${texto}` : texto));
23
+ if (exacto2 >= 0)
24
+ return exacto2;
25
+ }
26
+ const buscado = normal(texto);
27
+ const candidatos = els.map((e, i) => ({ e, i })).filter(({ e }) => e.visible && (!rol || e.rol === rol));
28
+ const puntaje = (e) => {
29
+ const n = normal(e.nombre), t = normal(e.texto);
30
+ const base = n === buscado || t === buscado ? 3 : n.includes(buscado) || t.includes(buscado) ? 1 : 0;
31
+ return base ? base + (INTERACTIVOS.has(e.rol) ? 1 : 0) : 0;
32
+ };
33
+ const mejor = candidatos.map((c) => ({ ...c, p: puntaje(c.e) })).filter((c) => c.p > 0).sort((a, b) => b.p - a.p)[0];
34
+ return mejor ? mejor.i : -1;
35
+ }
36
+ /** Lo más parecido a lo que pidió el agente, para decirle qué hay cuando no encuentra lo que busca. */
37
+ function parecidos(mapa, objetivo) {
38
+ const palabras = normal(objetivo).split(/[\s:]+/).filter((p) => p.length > 2);
39
+ return mapa.elementos.filter((e) => e.visible && INTERACTIVOS.has(e.rol))
40
+ .map((e) => ({ e, p: palabras.filter((w) => normal(e.nombre).includes(w) || normal(e.texto).includes(w)).length }))
41
+ .sort((a, b) => b.p - a.p).slice(0, 5).map(({ e }) => e.id);
42
+ }
43
+ export class SesionRender {
44
+ pagina;
45
+ mapa = null;
46
+ ultima = null;
47
+ red = [];
48
+ errores = [];
49
+ dialogos = [];
50
+ constructor(pagina) {
51
+ this.pagina = pagina;
52
+ pagina.on('response', (r) => {
53
+ const tipo = r.request().resourceType();
54
+ if (tipo === 'xhr' || tipo === 'fetch' || tipo === 'document')
55
+ this.red.push({ metodo: r.request().method(), url: r.url(), status: r.status() });
56
+ });
57
+ pagina.on('console', (m) => { if (m.type() === 'error')
58
+ this.errores.push(m.text().slice(0, 300)); });
59
+ pagina.on('pageerror', (e) => { this.errores.push(String(e?.message || e).slice(0, 300)); });
60
+ // 🩹 [Aprendido de MEITI, ojo_optico.js] Un alert()/confirm() sin contestar deja la página congelada y cuelga todo lo que
61
+ // sigue: se acepta solo y queda anotado como dialogo (el agente lo ve en la respuesta de la acción).
62
+ pagina.on('dialog', (d) => { this.dialogos.push(`${d.type()}: ${d.message().slice(0, 200)}`); d.accept().catch(() => { }); });
63
+ }
64
+ static async abrir(navegador, url, pantalla = { ancho: 1280, alto: 800 }, preparar) {
65
+ const pagina = await navegador.newPage();
66
+ await pagina.setViewport({ width: pantalla.ancho, height: pantalla.alto });
67
+ if (preparar)
68
+ await preparar(pagina); // por ejemplo, un servidor simulado (exámenes)
69
+ const sesion = new SesionRender(pagina);
70
+ await pagina.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 });
71
+ sesion.mapa = await escanearEstable(pagina);
72
+ return sesion;
73
+ }
74
+ async escanear() {
75
+ this.mapa = await escanear(this.pagina);
76
+ return this.mapa;
77
+ }
78
+ // El elemento real, en su marco (documento principal o el iframe de su molde, ver escaner.ts).
79
+ marcoDe(mapa, indice) {
80
+ const r = referenciaDe(mapa, indice);
81
+ if (!r || r.marco.detached)
82
+ throw new Error('the screen changed since the last scan: scan again');
83
+ return r;
84
+ }
85
+ async elemento(mapa, indice) {
86
+ const r = this.marcoDe(mapa, indice);
87
+ const h = await r.marco.evaluateHandle(`window.__renderRefs && window.__renderRefs[${r.indice}]`);
88
+ const el = h.asElement();
89
+ if (!el)
90
+ throw new Error('the element is no longer on the page');
91
+ return el;
92
+ }
93
+ /** Acción → escaneo → mapa nuevo → comparación con el anterior. */
94
+ async hacer(accion) {
95
+ const inicio = Date.now();
96
+ const antes = this.mapa ?? (await this.escanear());
97
+ this.red = [];
98
+ this.errores = [];
99
+ this.dialogos = [];
100
+ let objetivo = null;
101
+ try {
102
+ if ('objetivo' in accion) {
103
+ const i = resolver(antes, accion.objetivo);
104
+ if (i < 0) {
105
+ const hay = parecidos(antes, accion.objetivo);
106
+ throw new Error(`could not find "${accion.objetivo}" on the screen${hay.length ? `; on the screen there is: ${hay.join(', ')}` : ''}`);
107
+ }
108
+ objetivo = antes.elementos[i].id;
109
+ const el = await this.elemento(antes, i);
110
+ if (accion.tipo === 'clic')
111
+ await el.click();
112
+ else if (accion.tipo === 'llenar') {
113
+ await el.click({ count: 3 });
114
+ await this.pagina.keyboard.press('Backspace');
115
+ await el.type(accion.valor);
116
+ }
117
+ else if (accion.tipo === 'marcar') {
118
+ if (!!antes.elementos[i].estado?.marcado !== (accion.valor ?? true))
119
+ await el.click();
120
+ }
121
+ else if (accion.tipo === 'elegir') {
122
+ const elegido = await this.marcoDe(antes, i).marco.evaluate(`(function () {
123
+ var el = window.__renderRefs[${this.marcoDe(antes, i).indice}], v = ${JSON.stringify(accion.valor)}.toLowerCase();
124
+ for (var k = 0; k < el.options.length; k++) {
125
+ var o = el.options[k];
126
+ if (o.value.toLowerCase() === v || o.text.trim().toLowerCase() === v) { el.value = o.value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; }
127
+ }
128
+ return false;
129
+ })()`);
130
+ if (!elegido)
131
+ throw new Error(`"${accion.objetivo}" has no option "${accion.valor}"`);
132
+ }
133
+ }
134
+ else if (accion.tipo === 'tecla')
135
+ await this.pagina.keyboard.press(accion.tecla);
136
+ else if (accion.tipo === 'ir')
137
+ await this.pagina.goto(accion.url, { waitUntil: 'networkidle2', timeout: 60_000 });
138
+ // Que termine lo que la acción disparó (pedidos, animaciones cortas) antes de volver a escanear.
139
+ await this.pagina.waitForNetworkIdle({ idleTime: 300, timeout: 4_000 }).catch(() => { });
140
+ await new Promise((ok) => setTimeout(ok, 150));
141
+ const despues = await escanearEstable(this.pagina, { maxMs: 6_000 });
142
+ this.mapa = despues;
143
+ this.ultima = { accion, objetivo, ok: true, cambio: comparar(antes, despues), red: [...this.red], errores_pagina: [...this.errores], dialogos: [...this.dialogos], ms: Date.now() - inicio };
144
+ }
145
+ catch (error) {
146
+ this.ultima = { accion, objetivo, ok: false, error: String(error?.message || error), cambio: null, red: [...this.red], errores_pagina: [...this.errores], dialogos: [...this.dialogos], ms: Date.now() - inicio };
147
+ }
148
+ return this.ultima;
149
+ }
150
+ /** El agente dice qué espera; la sesión contesta sí o no, con la prueba. */
151
+ verificar(expectativas) {
152
+ const mapa = this.mapa;
153
+ const u = this.ultima;
154
+ return expectativas.map((x) => {
155
+ if (!mapa)
156
+ return { expectativa: x, cumple: false, prueba: 'no map yet' };
157
+ const buscar = (o) => { const i = resolver(mapa, o); return i >= 0 ? mapa.elementos[i] : null; };
158
+ if ('existe' in x) {
159
+ const e = buscar(x.existe);
160
+ return { expectativa: x, cumple: !!e, prueba: e ? describir(e) : `"${x.existe}" is not on the screen` };
161
+ }
162
+ if ('no_existe' in x) {
163
+ const e = buscar(x.no_existe);
164
+ return { expectativa: x, cumple: !e, prueba: e ? `it is there: ${describir(e)}` : 'not there' };
165
+ }
166
+ if ('texto' in x) {
167
+ const e = buscar(x.texto);
168
+ const dice = e ? [e.valor, e.texto, e.nombre].filter((v) => v !== undefined).join(' | ') : '';
169
+ return { expectativa: x, cumple: !!e && normal(dice).includes(normal(x.contiene)), prueba: e ? `${e.id} says "${dice}"` : `"${x.texto}" is not on the screen` };
170
+ }
171
+ if ('aparecio' in x || 'desaparecio' in x) {
172
+ const lista = 'aparecio' in x ? u?.cambio?.aparecieron : u?.cambio?.desaparecieron;
173
+ const buscado = normal('aparecio' in x ? x.aparecio : x.desaparecio);
174
+ const e = lista?.find((el) => el.id === ('aparecio' in x ? x.aparecio : x.desaparecio) || normal(el.nombre).includes(buscado) || normal(el.texto).includes(buscado));
175
+ return { expectativa: x, cumple: !!e, prueba: e ? describir(e) : `after the last action: ${u?.cambio?.resumen ?? 'no action yet'}` };
176
+ }
177
+ if ('sin_cambios' in x) {
178
+ const sin = !!u?.cambio?.sin_cambios;
179
+ return { expectativa: x, cumple: sin === x.sin_cambios, prueba: u?.cambio?.resumen ?? 'no action yet' };
180
+ }
181
+ if ('sin_fallas' in x) {
182
+ const fallas = mapa.fallas.filter((f) => x.sin_fallas === true || x.sin_fallas.includes(f.tipo));
183
+ return { expectativa: x, cumple: !fallas.length, prueba: fallas.length ? fallas.map((f) => `${f.tipo}${f.id ? ` en ${f.id}` : ''}: ${f.detalle}`).join('; ') : 'none' };
184
+ }
185
+ if ('sin_errores' in x) {
186
+ const err = u?.errores_pagina ?? [];
187
+ return { expectativa: x, cumple: !err.length, prueba: err.length ? err.join(' | ') : 'none' };
188
+ }
189
+ if ('pedido' in x) {
190
+ const p = x.pedido;
191
+ const coinciden = (u?.red ?? []).filter((r) => r.url.includes(p.url_contiene) && (!p.metodo || r.metodo === p.metodo.toUpperCase()));
192
+ const ok = coinciden.some((r) => p.status === undefined ? r.status < 400 : r.status === p.status);
193
+ return { expectativa: x, cumple: ok, prueba: coinciden.length ? coinciden.map((r) => `${r.metodo} ${r.url} → ${r.status}`).join('; ') : `no requests to "${p.url_contiene}"` };
194
+ }
195
+ return { expectativa: x, cumple: false, prueba: 'unknown expectation' };
196
+ });
197
+ }
198
+ async cerrar() { await this.pagina.close().catch(() => { }); }
199
+ }
package/mcp.mjs ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // 🛰️ @meiti/render: servidor MCP por stdio (JSON-RPC 2.0) para que un agente pruebe una pantalla sin fotos ni coordenadas.
3
+ // Abre una sesión de navegador y la mantiene: cada acción es acción → escaneo → mapa nuevo → comparación con el anterior
4
+ // (idea del usuario: "como un GPS y un escáner; comparar el mapa 1 con el mapa 2 es ver"). El motor vive en dist/ (armado
5
+ // desde Healer, sanador/src/render). Textos para agentes en inglés, como todo lo que es para clientes.
6
+ import readline from 'node:readline';
7
+ import puppeteer from 'puppeteer';
8
+ import { SesionRender, mapaEnTexto, rutasDeLaApp, recorrerApp, compararVersiones } from './dist/index.js';
9
+
10
+ const VERSION = '0.1.0';
11
+ const sesiones = new Map();
12
+ let navegador = null;
13
+ // Chrome: MEITI_RENDER_CHROME si se indica, el que descargó puppeteer, o el Chrome instalado en la máquina. Con npm 11+ los
14
+ // scripts de instalación pueden quedar bloqueados y puppeteer no descarga el suyo: entonces se usa el del sistema.
15
+ const ARGS = ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'];
16
+ async function lanzar() {
17
+ if (process.env.MEITI_RENDER_CHROME) return puppeteer.launch({ headless: true, args: ARGS, executablePath: process.env.MEITI_RENDER_CHROME });
18
+ try { return await puppeteer.launch({ headless: true, args: ARGS }); } catch (propio) {
19
+ try { return await puppeteer.launch({ headless: true, args: ARGS, channel: 'chrome' }); } catch {
20
+ throw new Error(`No Chrome found (${String(propio?.message || propio).split(String.fromCharCode(10))[0]}). Install one with "npx puppeteer browsers install chrome", or set MEITI_RENDER_CHROME to the path of a Chrome or Chromium executable.`);
21
+ }
22
+ }
23
+ }
24
+ const abrirNavegador = async () => (navegador ??= await lanzar());
25
+
26
+ const ACCIONES = { click: 'clic', fill: 'llenar', select: 'elegir', check: 'marcar', key: 'tecla', goto: 'ir' };
27
+ function accionInterna(a) {
28
+ const tipo = ACCIONES[a?.type];
29
+ if (!tipo) throw new Error('action.type is one of: click, fill, select, check, key, goto');
30
+ if (tipo === 'tecla') return { tipo, tecla: String(a.key || '') };
31
+ if (tipo === 'ir') return { tipo, url: String(a.url || '') };
32
+ if (!a.target) throw new Error('action.target is required (an id from the map, a label or a visible text)');
33
+ if (tipo === 'marcar') return { tipo, objetivo: String(a.target), valor: a.value === undefined ? true : !!a.value };
34
+ return { tipo, objetivo: String(a.target), ...(tipo === 'llenar' || tipo === 'elegir' ? { valor: String(a.value ?? '') } : {}) };
35
+ }
36
+ function expectativaInterna(x) {
37
+ if ('exists' in x) return { existe: String(x.exists) };
38
+ if ('not_exists' in x) return { no_existe: String(x.not_exists) };
39
+ if ('text' in x) return { texto: String(x.text), contiene: String(x.contains ?? '') };
40
+ if ('appeared' in x) return { aparecio: String(x.appeared) };
41
+ if ('disappeared' in x) return { desaparecio: String(x.disappeared) };
42
+ if ('no_changes' in x) return { sin_cambios: !!x.no_changes };
43
+ if ('no_issues' in x) return { sin_fallas: Array.isArray(x.no_issues) ? x.no_issues : true };
44
+ if ('no_errors' in x) return { sin_errores: true };
45
+ if ('request' in x) return { pedido: { metodo: x.request?.method, url_contiene: String(x.request?.url_contains ?? ''), status: x.request?.status } };
46
+ throw new Error(`Unknown expectation: ${JSON.stringify(x)}`);
47
+ }
48
+ const sesionDe = (id) => { const s = sesiones.get(String(id)); if (!s) throw new Error(`No open session "${id}". Call render_open first.`); return s; };
49
+ const fallasEnTexto = (fallas) => (fallas.length ? fallas.map((f) => `- ${f.tipo}${f.id ? ` (${f.id})` : ''}: ${f.detalle}`).join('\n') : 'none');
50
+
51
+ const HERRAMIENTAS = [
52
+ {
53
+ name: 'render_open',
54
+ description: 'Open a page in a real browser and return its MAP: every element that matters (role, name, text, value, state, position) plus visual issues measured exactly (horizontal overflow, cut text, covered or off-screen elements, small touch targets, low contrast, broken images, unnamed buttons). Works inside same-origin iframes. Returns a session id for render_act, render_expect and render_scan.',
55
+ inputSchema: { type: 'object', properties: { url: { type: 'string' }, width: { type: 'number', description: 'Viewport width. Default 1280; use 375 for a phone.' }, height: { type: 'number', description: 'Default 800.' } }, required: ['url'] },
56
+ async run(a) {
57
+ const sesion = await SesionRender.abrir(await abrirNavegador(), String(a.url), { ancho: Number(a.width) || 1280, alto: Number(a.height) || 800 });
58
+ const id = Math.random().toString(36).slice(2, 10);
59
+ sesiones.set(id, sesion);
60
+ return `Session ${id}\n\n${mapaEnTexto(sesion.mapa)}`;
61
+ },
62
+ },
63
+ {
64
+ name: 'render_act',
65
+ description: 'Do one action by INTENT (no coordinates) and return what changed on the screen: elements that appeared, disappeared, changed or moved (or "nothing changed": a control that does nothing), requests to the server with their status, page errors and native dialogs (accepted automatically). target is an id from the map (like "button:Save"), a label or a visible text; if it is not on the screen, the answer lists what is there.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ session: { type: 'string' },
70
+ action: { type: 'object', description: '{ "type": "click" | "fill" | "select" | "check" | "key" | "goto", "target": "...", "value": "...", "key": "Enter", "url": "..." }' },
71
+ include_map: { type: 'boolean', description: 'Also return the full new map. Default false (the change is usually enough).' },
72
+ },
73
+ required: ['session', 'action'],
74
+ },
75
+ async run(a) {
76
+ const sesion = sesionDe(a.session);
77
+ const r = await sesion.hacer(accionInterna(a.action));
78
+ if (!r.ok) return `Could not do it: ${r.error}`;
79
+ const red = r.red.filter((p) => !/\.(css|js|png|jpe?g|svg|woff2?|ico)(\?|$)/.test(p.url));
80
+ return [
81
+ `Acted on ${r.objetivo ?? '(page)'} in ${r.ms} ms.`,
82
+ `What changed: ${r.cambio?.resumen}`,
83
+ red.length ? `Requests:\n${red.map((p) => `- ${p.metodo} ${p.url} → ${p.status}`).join('\n')}` : 'Requests: none',
84
+ `Page errors: ${r.errores_pagina.length ? r.errores_pagina.join(' | ') : 'none'}`,
85
+ r.dialogos.length ? `Native dialogs (accepted): ${r.dialogos.join(' | ')}` : '',
86
+ r.cambio?.fallas_nuevas.length ? `New visual issues:\n${fallasEnTexto(r.cambio.fallas_nuevas)}` : '',
87
+ a.include_map ? `\n${mapaEnTexto(sesion.mapa)}` : '',
88
+ ].filter(Boolean).join('\n');
89
+ },
90
+ },
91
+ {
92
+ name: 'render_expect',
93
+ description: 'Check what you expect against the current screen and the last action; each answer is yes or no with its proof (the exact element or request). Expectations: {"exists": "..."}, {"not_exists": "..."}, {"text": "...", "contains": "..."}, {"appeared": "..."}, {"disappeared": "..."}, {"no_changes": true}, {"no_issues": true | ["contraste", ...]}, {"no_errors": true}, {"request": {"method": "POST", "url_contains": "/api/x", "status": 201}}.',
94
+ inputSchema: { type: 'object', properties: { session: { type: 'string' }, expectations: { type: 'array', items: { type: 'object' } } }, required: ['session', 'expectations'] },
95
+ async run(a) {
96
+ const resultados = sesionDe(a.session).verificar((a.expectations || []).map(expectativaInterna));
97
+ // Se devuelve la expectativa tal como la mandó el agente (no la forma interna).
98
+ return resultados.map((r, i) => `${r.cumple ? 'YES' : 'NO '} ${JSON.stringify(a.expectations[i])} · ${r.prueba}`).join('\n');
99
+ },
100
+ },
101
+ {
102
+ name: 'render_scan',
103
+ description: 'Scan the current screen again (waits until it is stable) and return the full map with its visual issues.',
104
+ inputSchema: { type: 'object', properties: { session: { type: 'string' } }, required: ['session'] },
105
+ async run(a) { const s = sesionDe(a.session); await s.escanear(); return mapaEnTexto(s.mapa); },
106
+ },
107
+ {
108
+ name: 'render_compare',
109
+ description: 'Compare two versions of the same app (for example before and after a change): the home page and up to 3 pages it links to, on a phone and a desktop. Says WORSE if a visual issue appears, an interactive element disappears or a page stops opening; BETTER if issues were fixed; SAME otherwise.',
110
+ inputSchema: { type: 'object', properties: { url_before: { type: 'string' }, url_after: { type: 'string' } }, required: ['url_before', 'url_after'] },
111
+ async run(a) {
112
+ const pagina = await (await abrirNavegador()).newPage();
113
+ try {
114
+ const rutas = await rutasDeLaApp(pagina, String(a.url_after)).catch(() => ['/']);
115
+ const despues = await recorrerApp(pagina, String(a.url_after), rutas);
116
+ const antes = await recorrerApp(pagina, String(a.url_before), rutas);
117
+ const r = compararVersiones(antes, despues);
118
+ const etiqueta = { peor: 'WORSE', mejor: 'BETTER', igual: 'SAME', sin_datos: 'NO DATA' }[r.veredicto];
119
+ return [`${etiqueta}: ${r.resumen}`, `Pages: ${rutas.join(', ')} (${r.pantallas} screens). Visual issues before ${r.fallas_antes}, after ${r.fallas_despues}.`,
120
+ ...r.regresiones.map((x) => `- ${x.tipo} · ${x.pantalla} · ${x.detalle}`), ...r.mejoras.map((m) => `+ fixed · ${m}`)].join('\n');
121
+ } finally { await pagina.close().catch(() => {}); }
122
+ },
123
+ },
124
+ {
125
+ name: 'render_close',
126
+ description: 'Close a session.',
127
+ inputSchema: { type: 'object', properties: { session: { type: 'string' } }, required: ['session'] },
128
+ async run(a) { const s = sesiones.get(String(a.session)); if (s) { await s.cerrar(); sesiones.delete(String(a.session)); } return 'Closed.'; },
129
+ },
130
+ ];
131
+
132
+ const responder = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
133
+ const fallar = (id, code, message) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
134
+ const cerrarTodo = async () => { if (navegador) await navegador.close().catch(() => {}); process.exit(0); };
135
+
136
+ const lector = readline.createInterface({ input: process.stdin });
137
+ lector.on('close', cerrarTodo);
138
+ lector.on('line', async (linea) => {
139
+ let m;
140
+ try { m = JSON.parse(linea); } catch { return; }
141
+ if (m.id === undefined) return;
142
+ try {
143
+ if (m.method === 'initialize') return responder(m.id, { protocolVersion: m.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'meiti-render', title: 'MEITI Render', version: VERSION, websiteUrl: 'https://meiti.dev/agents' }, instructions: 'Test screens like an agent, not like a person: render_open returns a map of the screen; render_act does one action by intent and tells you what changed; render_expect checks what you expect. Compare maps instead of looking at screenshots.' });
144
+ if (m.method === 'ping') return responder(m.id, {});
145
+ if (m.method === 'tools/list') return responder(m.id, { tools: HERRAMIENTAS.map(({ run, ...h }) => h) });
146
+ if (m.method === 'tools/call') {
147
+ const h = HERRAMIENTAS.find((x) => x.name === m.params?.name);
148
+ if (!h) return fallar(m.id, -32602, `Unknown tool: ${m.params?.name}`);
149
+ try { return responder(m.id, { content: [{ type: 'text', text: await h.run(m.params?.arguments || {}) }] }); }
150
+ catch (e) { return responder(m.id, { content: [{ type: 'text', text: String(e?.message || e) }], isError: true }); }
151
+ }
152
+ return fallar(m.id, -32601, `Method not supported: ${m.method}`);
153
+ } catch (e) {
154
+ return fallar(m.id, -32603, String(e?.message || e));
155
+ }
156
+ });
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@meiti/render",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Render testing built for AI agents: a structured map of the screen instead of screenshots, actions by intent instead of coordinates, and map-to-map comparison to see what changed. MCP server and library. Verified by Healer.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "MEITI (https://meiti.dev)",
8
+ "homepage": "https://meiti.dev/agents",
9
+ "repository": { "type": "git", "url": "git+https://github.com/meiti-dev/render.git" },
10
+ "bugs": { "url": "https://github.com/meiti-dev/render/issues" },
11
+ "keywords": ["mcp", "ai-agents", "testing", "render", "puppeteer", "accessibility", "visual-regression"],
12
+ "bin": { "meiti-render": "mcp.mjs" },
13
+ "exports": { ".": "./dist/index.js" },
14
+ "types": "./dist/index.d.ts",
15
+ "files": ["dist", "mcp.mjs", "README.md", "LICENSE"],
16
+ "engines": { "node": ">=18" },
17
+ "scripts": {
18
+ "build": "node armar.mjs",
19
+ "prepack": "node armar.mjs",
20
+ "test": "node prueba-mcp.mjs"
21
+ },
22
+ "dependencies": {
23
+ "puppeteer": "^25.16.0"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^7.0.2",
27
+ "@types/node": "^22.20.3"
28
+ }
29
+ }