@meiti/render 0.1.0 → 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/README.md +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/sesion.d.ts +37 -0
- package/dist/sesion.js +93 -3
- package/mcp.mjs +75 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Today an agent tests a screen like a person would: take a screenshot, send it to
|
|
|
9
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
10
|
- **What a screenshot never shows.** The requests sent to the server and their status, page errors, native dialogs.
|
|
11
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.
|
|
12
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.
|
|
13
14
|
|
|
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.
|
|
@@ -25,9 +26,9 @@ Tools:
|
|
|
25
26
|
|
|
26
27
|
| Tool | What it does |
|
|
27
28
|
|---|---|
|
|
28
|
-
| `render_open` | Opens a URL (any size: 375 for a phone) and returns the map
|
|
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. |
|
|
29
30
|
| `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_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. |
|
|
31
32
|
| `render_scan` | Scans the current screen again. |
|
|
32
33
|
| `render_compare` | Compares two versions of the same app. |
|
|
33
34
|
| `render_close` | Closes a session. |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
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';
|
|
2
|
+
export { SesionRender, resolver, type Accion, type Expectativa, type ResultadoAccion, type ResultadoExpectativa, type PedidoRed, type EstadoCarga, type HuellaPagina } from './sesion.js';
|
|
3
3
|
export { rutasDeLaApp, recorrerApp, compararVersiones, PANTALLAS, type RecorridoApp, type ResultadoVisual, type RegresionVisual } from './recorrido.js';
|
package/dist/sesion.d.ts
CHANGED
|
@@ -61,11 +61,35 @@ export type Expectativa = {
|
|
|
61
61
|
url_contiene: string;
|
|
62
62
|
status?: number;
|
|
63
63
|
};
|
|
64
|
+
} | {
|
|
65
|
+
cargada: true;
|
|
64
66
|
};
|
|
65
67
|
export interface ResultadoExpectativa {
|
|
66
68
|
expectativa: Expectativa;
|
|
67
69
|
cumple: boolean;
|
|
68
70
|
prueba: string;
|
|
71
|
+
sin_prueba?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export interface HuellaPagina {
|
|
74
|
+
url_contiene?: string;
|
|
75
|
+
titulo_contiene?: string;
|
|
76
|
+
titulo_es?: string;
|
|
77
|
+
existe?: string;
|
|
78
|
+
}
|
|
79
|
+
export interface EstadoCarga {
|
|
80
|
+
veredicto: 'cargada' | 'no_cargo' | 'incompleta' | 'otra_pagina';
|
|
81
|
+
motivos: string[];
|
|
82
|
+
http: number | null;
|
|
83
|
+
url: string;
|
|
84
|
+
titulo: string;
|
|
85
|
+
listo: string;
|
|
86
|
+
visibles: number;
|
|
87
|
+
texto: number;
|
|
88
|
+
pendientes: number;
|
|
89
|
+
huella?: {
|
|
90
|
+
cumple: boolean;
|
|
91
|
+
prueba: string;
|
|
92
|
+
};
|
|
69
93
|
}
|
|
70
94
|
/** El índice en el mapa del elemento al que se refiere el agente (-1 si no está). */
|
|
71
95
|
export declare function resolver(mapa: MapaPantalla, objetivo: string): number;
|
|
@@ -76,7 +100,20 @@ export declare class SesionRender {
|
|
|
76
100
|
private red;
|
|
77
101
|
private errores;
|
|
78
102
|
private dialogos;
|
|
103
|
+
estado: EstadoCarga | null;
|
|
104
|
+
huella: HuellaPagina | null;
|
|
105
|
+
private http;
|
|
106
|
+
private enCurso;
|
|
107
|
+
private errorNavegacion;
|
|
79
108
|
private constructor();
|
|
109
|
+
/**
|
|
110
|
+
* ¿Cargó la página? (y, con huella, ¿es la que se esperaba?). Se recalcula después de abrir y de cada acción.
|
|
111
|
+
* Señales del navegador, no adivinanzas: código HTTP, página de error del navegador, readyState, pedidos en curso,
|
|
112
|
+
* contenido visible y una pantalla que solo dice "error".
|
|
113
|
+
*/
|
|
114
|
+
medirCarga(): Promise<EstadoCarga>;
|
|
115
|
+
/** La huella de la página que se espera (algo que la identifica): sin ella, el navegador sabe si cargó, no si es la correcta. */
|
|
116
|
+
esperarPagina(huella: HuellaPagina): Promise<EstadoCarga>;
|
|
80
117
|
static abrir(navegador: Browser, url: string, pantalla?: {
|
|
81
118
|
ancho: number;
|
|
82
119
|
alto: number;
|
package/dist/sesion.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escanear, escanearEstable, comparar, referenciaDe } from './escaner.js';
|
|
2
|
+
const PANTALLA_DE_ERROR = /^(?:\s*(?:error\b|404\b|403\b|500\b|502\b|503\b)|[\s\S]*\b(?:not found|cannot get|application error|something went wrong|no se pudo cargar|page not found|internal server error|bad gateway|service unavailable)\b)/i;
|
|
2
3
|
const normal = (s) => (s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
3
4
|
const INTERACTIVOS = new Set(['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab', 'menuitem', 'switch', 'option']);
|
|
4
5
|
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}`;
|
|
@@ -47,6 +48,11 @@ export class SesionRender {
|
|
|
47
48
|
red = [];
|
|
48
49
|
errores = [];
|
|
49
50
|
dialogos = [];
|
|
51
|
+
estado = null;
|
|
52
|
+
huella = null;
|
|
53
|
+
http = null;
|
|
54
|
+
enCurso = 0;
|
|
55
|
+
errorNavegacion = null;
|
|
50
56
|
constructor(pagina) {
|
|
51
57
|
this.pagina = pagina;
|
|
52
58
|
pagina.on('response', (r) => {
|
|
@@ -60,6 +66,76 @@ export class SesionRender {
|
|
|
60
66
|
// 🩹 [Aprendido de MEITI, ojo_optico.js] Un alert()/confirm() sin contestar deja la página congelada y cuelga todo lo que
|
|
61
67
|
// sigue: se acepta solo y queda anotado como dialogo (el agente lo ve en la respuesta de la acción).
|
|
62
68
|
pagina.on('dialog', (d) => { this.dialogos.push(`${d.type()}: ${d.message().slice(0, 200)}`); d.accept().catch(() => { }); });
|
|
69
|
+
// El código del documento principal (la navegación del marco principal) y los pedidos que siguen en curso.
|
|
70
|
+
pagina.on('response', (r) => { if (r.request().isNavigationRequest() && r.frame() === pagina.mainFrame())
|
|
71
|
+
this.http = r.status(); });
|
|
72
|
+
pagina.on('request', () => { this.enCurso++; });
|
|
73
|
+
pagina.on('requestfinished', () => { this.enCurso = Math.max(0, this.enCurso - 1); });
|
|
74
|
+
pagina.on('requestfailed', (r) => { this.enCurso = Math.max(0, this.enCurso - 1); if (r.isNavigationRequest() && r.frame() === pagina.mainFrame())
|
|
75
|
+
this.errorNavegacion = r.failure()?.errorText || 'navigation failed'; });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* ¿Cargó la página? (y, con huella, ¿es la que se esperaba?). Se recalcula después de abrir y de cada acción.
|
|
79
|
+
* Señales del navegador, no adivinanzas: código HTTP, página de error del navegador, readyState, pedidos en curso,
|
|
80
|
+
* contenido visible y una pantalla que solo dice "error".
|
|
81
|
+
*/
|
|
82
|
+
async medirCarga() {
|
|
83
|
+
const pagina = this.pagina;
|
|
84
|
+
// Que la red se calme (hasta 3 s). Lo que siga abierto después (un canal en vivo, un sondeo) es normal en muchas apps: se
|
|
85
|
+
// informa en "pendientes", pero no baja el veredicto (medido en meiti.dev: siempre queda 1 abierto).
|
|
86
|
+
await pagina.waitForNetworkIdle({ idleTime: 300, timeout: 3_000 }).catch(() => { });
|
|
87
|
+
const info = (await pagina.evaluate(`({ listo: document.readyState, titulo: document.title || '', url: location.href, texto: (document.body && document.body.innerText || '').trim().length, primero: ((document.body && document.body.innerText) || '').trim().slice(0, 160) })`).catch(() => null));
|
|
88
|
+
const mapa = this.mapa;
|
|
89
|
+
const visibles = mapa ? mapa.elementos.filter((e) => e.visible).length : 0;
|
|
90
|
+
const motivos = [];
|
|
91
|
+
const url = info?.url || pagina.url();
|
|
92
|
+
if (this.errorNavegacion)
|
|
93
|
+
motivos.push(`the navigation failed: ${this.errorNavegacion}`);
|
|
94
|
+
if (url.startsWith('chrome-error://'))
|
|
95
|
+
motivos.push('the browser showed its own error page');
|
|
96
|
+
if (this.http === null && !this.errorNavegacion && !/^(about|data|blob):/.test(url))
|
|
97
|
+
motivos.push('the page document got no response');
|
|
98
|
+
if (this.http !== null && this.http >= 400)
|
|
99
|
+
motivos.push(`the page document answered HTTP ${this.http}`);
|
|
100
|
+
if (!info)
|
|
101
|
+
motivos.push('the page could not be read');
|
|
102
|
+
if (info && !info.texto && visibles === 0)
|
|
103
|
+
motivos.push('nothing visible on the page');
|
|
104
|
+
// Una pantalla con casi nada y que dice "error": cargó el documento, pero la app no.
|
|
105
|
+
if (info && visibles <= 3 && (PANTALLA_DE_ERROR.test(info.primero) || PANTALLA_DE_ERROR.test(info.titulo)))
|
|
106
|
+
motivos.push(`the screen only shows an error: "${(info.primero || info.titulo).slice(0, 100)}"`);
|
|
107
|
+
let veredicto = motivos.length ? 'no_cargo' : 'cargada';
|
|
108
|
+
if (veredicto === 'cargada' && info && info.listo !== 'complete') {
|
|
109
|
+
veredicto = 'incompleta';
|
|
110
|
+
motivos.push(`the document is still "${info.listo}"`);
|
|
111
|
+
}
|
|
112
|
+
let huella;
|
|
113
|
+
if (this.huella && veredicto !== 'no_cargo') {
|
|
114
|
+
const h = this.huella, fallan = [], ok = [];
|
|
115
|
+
if (h.url_contiene)
|
|
116
|
+
(url.includes(h.url_contiene) ? ok : fallan).push(`url ${url.includes(h.url_contiene) ? 'has' : 'does not have'} "${h.url_contiene}" (${url.slice(0, 120)})`);
|
|
117
|
+
if (h.titulo_contiene)
|
|
118
|
+
(normal(info?.titulo).includes(normal(h.titulo_contiene)) ? ok : fallan).push(`title "${info?.titulo || ''}"`);
|
|
119
|
+
// Título exacto: cuando el nombre de la app también aparece en otras páginas (una app llamada "Meiti" contra la de MEITI).
|
|
120
|
+
if (h.titulo_es)
|
|
121
|
+
(normal(info?.titulo) === normal(h.titulo_es) ? ok : fallan).push(`title "${info?.titulo || ''}"`);
|
|
122
|
+
if (h.existe) {
|
|
123
|
+
const i = mapa ? resolver(mapa, h.existe) : -1;
|
|
124
|
+
(i >= 0 ? ok : fallan).push(i >= 0 ? describir(mapa.elementos[i]) : `"${h.existe}" is not on the screen`);
|
|
125
|
+
}
|
|
126
|
+
huella = { cumple: !fallan.length, prueba: fallan.length ? fallan.join('; ') : ok.join('; ') };
|
|
127
|
+
if (!huella.cumple) {
|
|
128
|
+
veredicto = 'otra_pagina';
|
|
129
|
+
motivos.push(`it is not the expected page: ${huella.prueba}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
this.estado = { veredicto, motivos, http: this.http, url, titulo: info?.titulo || '', listo: info?.listo || '?', visibles, texto: info?.texto || 0, pendientes: this.enCurso, ...(huella ? { huella } : {}) };
|
|
133
|
+
return this.estado;
|
|
134
|
+
}
|
|
135
|
+
/** La huella de la página que se espera (algo que la identifica): sin ella, el navegador sabe si cargó, no si es la correcta. */
|
|
136
|
+
async esperarPagina(huella) {
|
|
137
|
+
this.huella = huella;
|
|
138
|
+
return this.medirCarga();
|
|
63
139
|
}
|
|
64
140
|
static async abrir(navegador, url, pantalla = { ancho: 1280, alto: 800 }, preparar) {
|
|
65
141
|
const pagina = await navegador.newPage();
|
|
@@ -67,8 +143,11 @@ export class SesionRender {
|
|
|
67
143
|
if (preparar)
|
|
68
144
|
await preparar(pagina); // por ejemplo, un servidor simulado (exámenes)
|
|
69
145
|
const sesion = new SesionRender(pagina);
|
|
70
|
-
|
|
146
|
+
// Una navegación que falla o tarda demasiado no corta: queda en el estado de carga ("no cargó", con el motivo).
|
|
147
|
+
await pagina.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 }).catch((e) => { if (!sesion.errorNavegacion)
|
|
148
|
+
sesion.errorNavegacion = String(e?.message || e).split('\n')[0].slice(0, 160); });
|
|
71
149
|
sesion.mapa = await escanearEstable(pagina);
|
|
150
|
+
await sesion.medirCarga();
|
|
72
151
|
return sesion;
|
|
73
152
|
}
|
|
74
153
|
async escanear() {
|
|
@@ -133,14 +212,18 @@ export class SesionRender {
|
|
|
133
212
|
}
|
|
134
213
|
else if (accion.tipo === 'tecla')
|
|
135
214
|
await this.pagina.keyboard.press(accion.tecla);
|
|
136
|
-
else if (accion.tipo === 'ir')
|
|
137
|
-
|
|
215
|
+
else if (accion.tipo === 'ir') {
|
|
216
|
+
this.http = null;
|
|
217
|
+
this.errorNavegacion = null;
|
|
218
|
+
await this.pagina.goto(accion.url, { waitUntil: 'networkidle2', timeout: 60_000 }).catch((e) => { this.errorNavegacion = String(e?.message || e).split('\n')[0].slice(0, 160); });
|
|
219
|
+
}
|
|
138
220
|
// Que termine lo que la acción disparó (pedidos, animaciones cortas) antes de volver a escanear.
|
|
139
221
|
await this.pagina.waitForNetworkIdle({ idleTime: 300, timeout: 4_000 }).catch(() => { });
|
|
140
222
|
await new Promise((ok) => setTimeout(ok, 150));
|
|
141
223
|
const despues = await escanearEstable(this.pagina, { maxMs: 6_000 });
|
|
142
224
|
this.mapa = despues;
|
|
143
225
|
this.ultima = { accion, objetivo, ok: true, cambio: comparar(antes, despues), red: [...this.red], errores_pagina: [...this.errores], dialogos: [...this.dialogos], ms: Date.now() - inicio };
|
|
226
|
+
await this.medirCarga();
|
|
144
227
|
}
|
|
145
228
|
catch (error) {
|
|
146
229
|
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 };
|
|
@@ -151,9 +234,16 @@ export class SesionRender {
|
|
|
151
234
|
verificar(expectativas) {
|
|
152
235
|
const mapa = this.mapa;
|
|
153
236
|
const u = this.ultima;
|
|
237
|
+
const estado = this.estado;
|
|
238
|
+
const sinCarga = estado && estado.veredicto !== 'cargada' && estado.veredicto !== 'incompleta' ? `cannot prove it: ${estado.veredicto === 'otra_pagina' ? 'this is not the expected page' : 'the page did not load'} (${estado.motivos.join('; ')})` : null;
|
|
154
239
|
return expectativas.map((x) => {
|
|
155
240
|
if (!mapa)
|
|
156
241
|
return { expectativa: x, cumple: false, prueba: 'no map yet' };
|
|
242
|
+
if ('cargada' in x)
|
|
243
|
+
return { expectativa: x, cumple: estado?.veredicto === 'cargada', prueba: estado ? `${estado.veredicto}${estado.motivos.length ? `: ${estado.motivos.join('; ')}` : ` (HTTP ${estado.http ?? '?'}, ${estado.visibles} visible elements)`}` : 'not measured' };
|
|
244
|
+
// Lo negativo ("no está", "no cambió", "desapareció") con una página que no cargó, o que no es la esperada, no es prueba.
|
|
245
|
+
if (sinCarga && ('no_existe' in x || 'sin_cambios' in x || 'desaparecio' in x))
|
|
246
|
+
return { expectativa: x, cumple: false, sin_prueba: true, prueba: sinCarga };
|
|
157
247
|
const buscar = (o) => { const i = resolver(mapa, o); return i >= 0 ? mapa.elementos[i] : null; };
|
|
158
248
|
if ('existe' in x) {
|
|
159
249
|
const e = buscar(x.existe);
|
package/mcp.mjs
CHANGED
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
// desde Healer, sanador/src/render). Textos para agentes en inglés, como todo lo que es para clientes.
|
|
6
6
|
import readline from 'node:readline';
|
|
7
7
|
import puppeteer from 'puppeteer';
|
|
8
|
-
import { SesionRender, mapaEnTexto, rutasDeLaApp, recorrerApp, compararVersiones } from './dist/index.js';
|
|
8
|
+
import { SesionRender, mapaEnTexto, rutasDeLaApp, recorrerApp, compararVersiones, resolver } from './dist/index.js';
|
|
9
9
|
|
|
10
|
-
const VERSION = '0.
|
|
10
|
+
const VERSION = '0.2.0';
|
|
11
11
|
const sesiones = new Map();
|
|
12
|
+
// 0.2.0 (2026-11-15, aprendido probando apps reales de MEITI con login): cada sesión en su propio contexto (nada se filtra de
|
|
13
|
+
// una a otra), sesión cargada ANTES de abrir (storage, cookies, headers), esperar un elemento que llega tarde (wait_for) y los
|
|
14
|
+
// pedidos que fallan al cargar. id de sesión → { contexto, fallidos }.
|
|
15
|
+
const extras = new Map();
|
|
16
|
+
const espera = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
// 🧱 El estado de carga en una línea (ver EstadoCarga en sesion.ts): cargada, no cargó, incompleta u otra página.
|
|
18
|
+
const ETIQUETA_CARGA = { cargada: 'LOADED', no_cargo: 'DID NOT LOAD', incompleta: 'STILL LOADING', otra_pagina: 'NOT THE EXPECTED PAGE' };
|
|
19
|
+
function estadoEnTexto(e) {
|
|
20
|
+
if (!e) return 'Page: not measured';
|
|
21
|
+
const datos = `HTTP ${e.http ?? 'none'}, ${e.visibles} visible elements${e.pendientes ? `, ${e.pendientes} requests in flight` : ''}`;
|
|
22
|
+
return `Page: ${ETIQUETA_CARGA[e.veredicto] || e.veredicto} (${datos})${e.motivos.length ? ` · ${e.motivos.join('; ')}` : ''}${e.huella?.cumple ? ` · expected page: ${e.huella.prueba}` : ''}`;
|
|
23
|
+
}
|
|
24
|
+
const huellaInterna = (x) => (x && typeof x === 'object' ? { ...(x.url_contains ? { url_contiene: String(x.url_contains) } : {}), ...(x.title_contains ? { titulo_contiene: String(x.title_contains) } : {}), ...(x.title_is ? { titulo_es: String(x.title_is) } : {}), ...(x.exists ? { existe: String(x.exists) } : {}) } : null);
|
|
12
25
|
let navegador = null;
|
|
13
26
|
// Chrome: MEITI_RENDER_CHROME si se indica, el que descargó puppeteer, o el Chrome instalado en la máquina. Con npm 11+ los
|
|
14
27
|
// scripts de instalación pueden quedar bloqueados y puppeteer no descarga el suyo: entonces se usa el del sistema.
|
|
@@ -43,6 +56,7 @@ function expectativaInterna(x) {
|
|
|
43
56
|
if ('no_issues' in x) return { sin_fallas: Array.isArray(x.no_issues) ? x.no_issues : true };
|
|
44
57
|
if ('no_errors' in x) return { sin_errores: true };
|
|
45
58
|
if ('request' in x) return { pedido: { metodo: x.request?.method, url_contiene: String(x.request?.url_contains ?? ''), status: x.request?.status } };
|
|
59
|
+
if ('loaded' in x) return { cargada: true };
|
|
46
60
|
throw new Error(`Unknown expectation: ${JSON.stringify(x)}`);
|
|
47
61
|
}
|
|
48
62
|
const sesionDe = (id) => { const s = sesiones.get(String(id)); if (!s) throw new Error(`No open session "${id}". Call render_open first.`); return s; };
|
|
@@ -52,12 +66,50 @@ const HERRAMIENTAS = [
|
|
|
52
66
|
{
|
|
53
67
|
name: 'render_open',
|
|
54
68
|
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: {
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
url: { type: 'string' },
|
|
73
|
+
width: { type: 'number', description: 'Viewport width. Default 1280; use 375 for a phone.' },
|
|
74
|
+
height: { type: 'number', description: 'Default 800.' },
|
|
75
|
+
storage: { type: 'object', description: 'localStorage entries to set on the page origin BEFORE it loads, as { key: value }. Use it to open an app already signed in (the same keys its login leaves), without driving the login screen.' },
|
|
76
|
+
cookies: { type: 'array', items: { type: 'object' }, description: 'Cookies to set before loading, on the page origin: [{ "name": "...", "value": "..." }].' },
|
|
77
|
+
headers: { type: 'object', description: 'Extra HTTP headers sent with every request of this session, as { name: value }.' },
|
|
78
|
+
wait_for: { type: 'string', description: 'An element that appears late (after a request): wait up to 15 s for it (an id from the map, a label or a visible text) before returning the map.' },
|
|
79
|
+
expect_page: { type: 'object', description: 'What identifies the page you meant to open: { "url_contains": "...", "title_contains": "...", "title_is": "...", "exists": "..." } (any of them; title_is is an exact title, for names that also appear on other pages). The browser knows whether a page loaded, not whether it is the RIGHT page: with this, a different page (a landing page, a login, a redirect) is reported as NOT THE EXPECTED PAGE, and negative checks on it cannot pass.' }
|
|
80
|
+
},
|
|
81
|
+
required: ['url']
|
|
82
|
+
},
|
|
56
83
|
async run(a) {
|
|
57
|
-
const
|
|
84
|
+
const url = String(a.url);
|
|
85
|
+
const origen = new URL(url).origin;
|
|
86
|
+
// Un contexto propio por sesión: lo que una guarda (storage, cookies) no lo ve la siguiente.
|
|
87
|
+
const contexto = await (await abrirNavegador()).createBrowserContext();
|
|
88
|
+
if (a.storage && typeof a.storage === 'object' && Object.keys(a.storage).length) {
|
|
89
|
+
const previa = await contexto.newPage();
|
|
90
|
+
await previa.goto(origen + '/robots.txt', { waitUntil: 'domcontentloaded' }).catch(() => {});
|
|
91
|
+
await previa.evaluate((pares) => { for (const [k, v] of pares) localStorage.setItem(k, String(v)); }, Object.entries(a.storage)).catch(() => {});
|
|
92
|
+
await previa.close();
|
|
93
|
+
}
|
|
94
|
+
const fallidos = [];
|
|
95
|
+
const sesion = await SesionRender.abrir(contexto, url, { ancho: Number(a.width) || 1280, alto: Number(a.height) || 800 }, async (pagina) => {
|
|
96
|
+
if (Array.isArray(a.cookies) && a.cookies.length) await pagina.setCookie(...a.cookies.map((c) => ({ url: origen, name: String(c.name), value: String(c.value ?? '') })));
|
|
97
|
+
if (a.headers && typeof a.headers === 'object') await pagina.setExtraHTTPHeaders(Object.fromEntries(Object.entries(a.headers).map(([k, v]) => [k, String(v)])));
|
|
98
|
+
pagina.on('response', (r) => { if (r.status() >= 400 && fallidos.length < 30) fallidos.push(`${r.status()} ${r.request().method()} ${r.url().replace(/[?#].*/, '')}`); });
|
|
99
|
+
});
|
|
100
|
+
if (a.wait_for) {
|
|
101
|
+
const hasta = Date.now() + 15000;
|
|
102
|
+
while (Date.now() < hasta && resolver(sesion.mapa, String(a.wait_for)) < 0) { await espera(500); await sesion.escanear(); }
|
|
103
|
+
}
|
|
104
|
+
if (a.expect_page) await sesion.esperarPagina(huellaInterna(a.expect_page)); else await sesion.medirCarga();
|
|
58
105
|
const id = Math.random().toString(36).slice(2, 10);
|
|
59
106
|
sesiones.set(id, sesion);
|
|
60
|
-
|
|
107
|
+
extras.set(id, { contexto, fallidos });
|
|
108
|
+
const partes = [`Session ${id}`, estadoEnTexto(sesion.estado)];
|
|
109
|
+
if (a.wait_for) partes.push(resolver(sesion.mapa, String(a.wait_for)) >= 0 ? `Waited for "${a.wait_for}": it is there.` : `Waited 15 s for "${a.wait_for}": it did not appear.`);
|
|
110
|
+
if (fallidos.length) partes.push(`Requests that failed while loading:\n${fallidos.map((f) => `- ${f}`).join('\n')}`);
|
|
111
|
+
partes.push('', mapaEnTexto(sesion.mapa));
|
|
112
|
+
return partes.join('\n');
|
|
61
113
|
},
|
|
62
114
|
},
|
|
63
115
|
{
|
|
@@ -80,6 +132,7 @@ const HERRAMIENTAS = [
|
|
|
80
132
|
return [
|
|
81
133
|
`Acted on ${r.objetivo ?? '(page)'} in ${r.ms} ms.`,
|
|
82
134
|
`What changed: ${r.cambio?.resumen}`,
|
|
135
|
+
sesion.estado && sesion.estado.veredicto !== 'cargada' ? estadoEnTexto(sesion.estado) : '',
|
|
83
136
|
red.length ? `Requests:\n${red.map((p) => `- ${p.metodo} ${p.url} → ${p.status}`).join('\n')}` : 'Requests: none',
|
|
84
137
|
`Page errors: ${r.errores_pagina.length ? r.errores_pagina.join(' | ') : 'none'}`,
|
|
85
138
|
r.dialogos.length ? `Native dialogs (accepted): ${r.dialogos.join(' | ')}` : '',
|
|
@@ -90,19 +143,25 @@ const HERRAMIENTAS = [
|
|
|
90
143
|
},
|
|
91
144
|
{
|
|
92
145
|
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}}.',
|
|
146
|
+
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, "ignore": ["text of errors that are not yours, like a blocked analytics script"]}, {"request": {"method": "POST", "url_contains": "/api/x", "status": 201}}, {"loaded": true}. A negative check ("not_exists", "no_changes", "disappeared") on a page that did not load, or that is not the expected page, answers CANNOT PROVE instead of YES. Tip: "not_exists" only proves something if the page loaded: check something that must be there too.',
|
|
94
147
|
inputSchema: { type: 'object', properties: { session: { type: 'string' }, expectations: { type: 'array', items: { type: 'object' } } }, required: ['session', 'expectations'] },
|
|
95
148
|
async run(a) {
|
|
96
149
|
const resultados = sesionDe(a.session).verificar((a.expectations || []).map(expectativaInterna));
|
|
150
|
+
// no_errors con "ignore": los errores que no son de la app (un script de analítica bloqueado) no cuentan.
|
|
151
|
+
(a.expectations || []).forEach((x, i) => {
|
|
152
|
+
if (!x || !('no_errors' in x) || !Array.isArray(x.ignore) || resultados[i].cumple) return;
|
|
153
|
+
const propios = String(resultados[i].prueba).split(' | ').filter((e) => !x.ignore.some((patron) => e.includes(String(patron))));
|
|
154
|
+
resultados[i] = { ...resultados[i], cumple: propios.length === 0, prueba: propios.length ? propios.join(' | ') : 'no errors of its own (some ignored)' };
|
|
155
|
+
});
|
|
97
156
|
// 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');
|
|
157
|
+
return resultados.map((r, i) => `${r.sin_prueba ? 'CANNOT PROVE' : r.cumple ? 'YES' : 'NO '} ${JSON.stringify(a.expectations[i])} · ${r.prueba}`).join('\n');
|
|
99
158
|
},
|
|
100
159
|
},
|
|
101
160
|
{
|
|
102
161
|
name: 'render_scan',
|
|
103
162
|
description: 'Scan the current screen again (waits until it is stable) and return the full map with its visual issues.',
|
|
104
163
|
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)
|
|
164
|
+
async run(a) { const s = sesionDe(a.session); await s.escanear(); await s.medirCarga(); return `${estadoEnTexto(s.estado)}\n\n${mapaEnTexto(s.mapa)}`; },
|
|
106
165
|
},
|
|
107
166
|
{
|
|
108
167
|
name: 'render_compare',
|
|
@@ -125,7 +184,14 @@ const HERRAMIENTAS = [
|
|
|
125
184
|
name: 'render_close',
|
|
126
185
|
description: 'Close a session.',
|
|
127
186
|
inputSchema: { type: 'object', properties: { session: { type: 'string' } }, required: ['session'] },
|
|
128
|
-
async run(a) {
|
|
187
|
+
async run(a) {
|
|
188
|
+
const id = String(a.session);
|
|
189
|
+
const s = sesiones.get(id);
|
|
190
|
+
if (s) { await s.cerrar().catch(() => {}); sesiones.delete(id); }
|
|
191
|
+
const x = extras.get(id);
|
|
192
|
+
if (x) { await x.contexto.close().catch(() => {}); extras.delete(id); }
|
|
193
|
+
return 'Closed.';
|
|
194
|
+
},
|
|
129
195
|
},
|
|
130
196
|
];
|
|
131
197
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meiti/render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|