@meiti/render 0.0.0-stage → 0.2.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,73 @@
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
+ - **Knows whether the page loaded, and whether it is the right one.** Every map starts with the page state: LOADED, DID NOT LOAD (HTTP error, the browser's own error page, nothing visible, a screen that only shows an error) or NOT THE EXPECTED PAGE (with `expect_page`: something that identifies the page you meant to open). On a page that did not load or is not the expected one, negative checks like "not there" answer CANNOT PROVE instead of a false yes.
13
+ - **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.
14
+
15
+ 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.
16
+
17
+ ## Use it from an agent (MCP)
18
+
19
+ ```bash
20
+ npx -y @meiti/render
21
+ ```
22
+
23
+ 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`.
24
+
25
+ Tools:
26
+
27
+ | Tool | What it does |
28
+ |---|---|
29
+ | `render_open` | Opens a URL (any size: 375 for a phone) and returns the map, its visual issues and the requests that failed while loading. Optional: `storage`, `cookies` and `headers` to open it already signed in, and `wait_for` for an element that appears after a request. Each session runs in its own browser context. |
30
+ | `render_act` | One action by intent (`click`, `fill`, `select`, `check`, `key`, `goto`) and what changed. |
31
+ | `render_expect` | Checks what you expect: yes or no, with the exact proof. `no_errors` takes `ignore` for errors that are not the app's own. |
32
+ | `render_scan` | Scans the current screen again. |
33
+ | `render_compare` | Compares two versions of the same app. |
34
+ | `render_close` | Closes a session. |
35
+
36
+ Example of what the agent reads after `render_act {"type": "click", "target": "Add"}`:
37
+
38
+ ```
39
+ Acted on button:Add in 858 ms.
40
+ What changed: appeared 1: listitem:Milk; moved 4
41
+ Requests:
42
+ - POST http://localhost:3000/api/items → 201
43
+ Page errors: none
44
+ ```
45
+
46
+ ## Use it from code
47
+
48
+ ```js
49
+ import puppeteer from 'puppeteer';
50
+ import { SesionRender } from '@meiti/render';
51
+
52
+ const browser = await puppeteer.launch();
53
+ const session = await SesionRender.abrir(browser, 'http://localhost:3000', { ancho: 375, alto: 812 });
54
+ await session.hacer({ tipo: 'llenar', objetivo: 'Email', valor: 'ana@example.com' });
55
+ const result = await session.hacer({ tipo: 'clic', objetivo: 'Save' });
56
+ console.log(result.cambio.resumen);
57
+ console.log(session.verificar([{ pedido: { metodo: 'POST', url_contiene: '/api/users', status: 201 } }]));
58
+ ```
59
+
60
+ ## Verified by Healer
61
+
62
+ `@meiti/render` is a MEITI tool, verified by [Healer](https://meiti.dev/healer) with exams that have known answers, run on every version:
63
+
64
+ | Exam | Result |
65
+ |---|---|
66
+ | Visual issues (one planted issue per page, plus clean pages) | 11 of 11, 0 false alarms, ~4 ms per scan |
67
+ | Actions and expectations (JavaScript error, silent 500, missing button) | 10 of 10 |
68
+ | Apps rendered in iframes, loading states, native dialogs | 7 of 7 |
69
+ | Before and after (worse, better, same) | 3 of 3 |
70
+
71
+ ## License
72
+
73
+ 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, type EstadoCarga, type HuellaPagina } 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
+ }