@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 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 and its visual issues. |
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
- await pagina.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 });
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
- await this.pagina.goto(accion.url, { waitUntil: 'networkidle2', timeout: 60_000 });
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.1.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: { 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'] },
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 sesion = await SesionRender.abrir(await abrirNavegador(), String(a.url), { ancho: Number(a.width) || 1280, alto: Number(a.height) || 800 });
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
- return `Session ${id}\n\n${mapaEnTexto(sesion.mapa)}`;
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) { const s = sesiones.get(String(a.session)); if (s) { await s.cerrar(); sesiones.delete(String(a.session)); } return 'Closed.'; },
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.1.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",