@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 +21 -0
- package/README.md +72 -2
- package/dist/escaner-navegador.js +160 -0
- package/dist/escaner.d.ts +94 -0
- package/dist/escaner.js +162 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/recorrido.d.ts +37 -0
- package/dist/recorrido.js +85 -0
- package/dist/sesion.d.ts +129 -0
- package/dist/sesion.js +289 -0
- package/mcp.mjs +222 -0
- package/package.json +27 -4
package/dist/sesion.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Browser, Page } from 'puppeteer';
|
|
2
|
+
import { type MapaPantalla, type ComparacionMapas } from './escaner.js';
|
|
3
|
+
export type Accion = {
|
|
4
|
+
tipo: 'clic';
|
|
5
|
+
objetivo: string;
|
|
6
|
+
} | {
|
|
7
|
+
tipo: 'llenar';
|
|
8
|
+
objetivo: string;
|
|
9
|
+
valor: string;
|
|
10
|
+
} | {
|
|
11
|
+
tipo: 'elegir';
|
|
12
|
+
objetivo: string;
|
|
13
|
+
valor: string;
|
|
14
|
+
} | {
|
|
15
|
+
tipo: 'marcar';
|
|
16
|
+
objetivo: string;
|
|
17
|
+
valor?: boolean;
|
|
18
|
+
} | {
|
|
19
|
+
tipo: 'tecla';
|
|
20
|
+
tecla: string;
|
|
21
|
+
} | {
|
|
22
|
+
tipo: 'ir';
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
export interface PedidoRed {
|
|
26
|
+
metodo: string;
|
|
27
|
+
url: string;
|
|
28
|
+
status: number;
|
|
29
|
+
}
|
|
30
|
+
export interface ResultadoAccion {
|
|
31
|
+
accion: Accion;
|
|
32
|
+
objetivo: string | null;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
cambio: ComparacionMapas | null;
|
|
36
|
+
red: PedidoRed[];
|
|
37
|
+
errores_pagina: string[];
|
|
38
|
+
dialogos: string[];
|
|
39
|
+
ms: number;
|
|
40
|
+
}
|
|
41
|
+
export type Expectativa = {
|
|
42
|
+
existe: string;
|
|
43
|
+
} | {
|
|
44
|
+
no_existe: string;
|
|
45
|
+
} | {
|
|
46
|
+
texto: string;
|
|
47
|
+
contiene: string;
|
|
48
|
+
} | {
|
|
49
|
+
aparecio: string;
|
|
50
|
+
} | {
|
|
51
|
+
desaparecio: string;
|
|
52
|
+
} | {
|
|
53
|
+
sin_cambios: boolean;
|
|
54
|
+
} | {
|
|
55
|
+
sin_fallas: true | string[];
|
|
56
|
+
} | {
|
|
57
|
+
sin_errores: true;
|
|
58
|
+
} | {
|
|
59
|
+
pedido: {
|
|
60
|
+
metodo?: string;
|
|
61
|
+
url_contiene: string;
|
|
62
|
+
status?: number;
|
|
63
|
+
};
|
|
64
|
+
} | {
|
|
65
|
+
cargada: true;
|
|
66
|
+
};
|
|
67
|
+
export interface ResultadoExpectativa {
|
|
68
|
+
expectativa: Expectativa;
|
|
69
|
+
cumple: boolean;
|
|
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
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** El índice en el mapa del elemento al que se refiere el agente (-1 si no está). */
|
|
95
|
+
export declare function resolver(mapa: MapaPantalla, objetivo: string): number;
|
|
96
|
+
export declare class SesionRender {
|
|
97
|
+
readonly pagina: Page;
|
|
98
|
+
mapa: MapaPantalla | null;
|
|
99
|
+
ultima: ResultadoAccion | null;
|
|
100
|
+
private red;
|
|
101
|
+
private errores;
|
|
102
|
+
private dialogos;
|
|
103
|
+
estado: EstadoCarga | null;
|
|
104
|
+
huella: HuellaPagina | null;
|
|
105
|
+
private http;
|
|
106
|
+
private enCurso;
|
|
107
|
+
private errorNavegacion;
|
|
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>;
|
|
117
|
+
static abrir(navegador: Browser, url: string, pantalla?: {
|
|
118
|
+
ancho: number;
|
|
119
|
+
alto: number;
|
|
120
|
+
}, preparar?: (pagina: Page) => Promise<void>): Promise<SesionRender>;
|
|
121
|
+
escanear(): Promise<MapaPantalla>;
|
|
122
|
+
private marcoDe;
|
|
123
|
+
private elemento;
|
|
124
|
+
/** Acción → escaneo → mapa nuevo → comparación con el anterior. */
|
|
125
|
+
hacer(accion: Accion): Promise<ResultadoAccion>;
|
|
126
|
+
/** El agente dice qué espera; la sesión contesta sí o no, con la prueba. */
|
|
127
|
+
verificar(expectativas: Expectativa[]): ResultadoExpectativa[];
|
|
128
|
+
cerrar(): Promise<void>;
|
|
129
|
+
}
|
package/dist/sesion.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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;
|
|
3
|
+
const normal = (s) => (s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
4
|
+
const INTERACTIVOS = new Set(['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab', 'menuitem', 'switch', 'option']);
|
|
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}`;
|
|
6
|
+
/** El índice en el mapa del elemento al que se refiere el agente (-1 si no está). */
|
|
7
|
+
export function resolver(mapa, objetivo) {
|
|
8
|
+
const els = mapa.elementos;
|
|
9
|
+
const exacto = els.findIndex((e) => e.id === objetivo);
|
|
10
|
+
if (exacto >= 0)
|
|
11
|
+
return exacto;
|
|
12
|
+
// 🩹 [2026-11-13, examen de uso real v2: el modelo copió la línea del mapa, 'combobox "Categoría"', y no se encontraba] Se acepta
|
|
13
|
+
// el objetivo como lo escribe un modelo: "rol:nombre", 'rol "nombre"' (con o sin la comilla de cierre) y lo que sobra después de
|
|
14
|
+
// un " | " o un " = " (restos del formato de la respuesta) se descarta.
|
|
15
|
+
const limpio = objetivo.split(/\s+[|=]\s*/)[0].trim();
|
|
16
|
+
let rol = null, texto = limpio;
|
|
17
|
+
const m = /^([a-z]+):(.+)$/.exec(limpio) || /^([a-z]+)\s+["'“‘](.+?)["'”’]?$/.exec(limpio);
|
|
18
|
+
if (m && els.some((e) => e.rol === m[1])) {
|
|
19
|
+
rol = m[1];
|
|
20
|
+
texto = m[2].trim();
|
|
21
|
+
}
|
|
22
|
+
if (texto !== objetivo) {
|
|
23
|
+
const exacto2 = els.findIndex((e) => e.id === (rol ? `${rol}:${texto}` : texto));
|
|
24
|
+
if (exacto2 >= 0)
|
|
25
|
+
return exacto2;
|
|
26
|
+
}
|
|
27
|
+
const buscado = normal(texto);
|
|
28
|
+
const candidatos = els.map((e, i) => ({ e, i })).filter(({ e }) => e.visible && (!rol || e.rol === rol));
|
|
29
|
+
const puntaje = (e) => {
|
|
30
|
+
const n = normal(e.nombre), t = normal(e.texto);
|
|
31
|
+
const base = n === buscado || t === buscado ? 3 : n.includes(buscado) || t.includes(buscado) ? 1 : 0;
|
|
32
|
+
return base ? base + (INTERACTIVOS.has(e.rol) ? 1 : 0) : 0;
|
|
33
|
+
};
|
|
34
|
+
const mejor = candidatos.map((c) => ({ ...c, p: puntaje(c.e) })).filter((c) => c.p > 0).sort((a, b) => b.p - a.p)[0];
|
|
35
|
+
return mejor ? mejor.i : -1;
|
|
36
|
+
}
|
|
37
|
+
/** Lo más parecido a lo que pidió el agente, para decirle qué hay cuando no encuentra lo que busca. */
|
|
38
|
+
function parecidos(mapa, objetivo) {
|
|
39
|
+
const palabras = normal(objetivo).split(/[\s:]+/).filter((p) => p.length > 2);
|
|
40
|
+
return mapa.elementos.filter((e) => e.visible && INTERACTIVOS.has(e.rol))
|
|
41
|
+
.map((e) => ({ e, p: palabras.filter((w) => normal(e.nombre).includes(w) || normal(e.texto).includes(w)).length }))
|
|
42
|
+
.sort((a, b) => b.p - a.p).slice(0, 5).map(({ e }) => e.id);
|
|
43
|
+
}
|
|
44
|
+
export class SesionRender {
|
|
45
|
+
pagina;
|
|
46
|
+
mapa = null;
|
|
47
|
+
ultima = null;
|
|
48
|
+
red = [];
|
|
49
|
+
errores = [];
|
|
50
|
+
dialogos = [];
|
|
51
|
+
estado = null;
|
|
52
|
+
huella = null;
|
|
53
|
+
http = null;
|
|
54
|
+
enCurso = 0;
|
|
55
|
+
errorNavegacion = null;
|
|
56
|
+
constructor(pagina) {
|
|
57
|
+
this.pagina = pagina;
|
|
58
|
+
pagina.on('response', (r) => {
|
|
59
|
+
const tipo = r.request().resourceType();
|
|
60
|
+
if (tipo === 'xhr' || tipo === 'fetch' || tipo === 'document')
|
|
61
|
+
this.red.push({ metodo: r.request().method(), url: r.url(), status: r.status() });
|
|
62
|
+
});
|
|
63
|
+
pagina.on('console', (m) => { if (m.type() === 'error')
|
|
64
|
+
this.errores.push(m.text().slice(0, 300)); });
|
|
65
|
+
pagina.on('pageerror', (e) => { this.errores.push(String(e?.message || e).slice(0, 300)); });
|
|
66
|
+
// 🩹 [Aprendido de MEITI, ojo_optico.js] Un alert()/confirm() sin contestar deja la página congelada y cuelga todo lo que
|
|
67
|
+
// sigue: se acepta solo y queda anotado como dialogo (el agente lo ve en la respuesta de la acción).
|
|
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();
|
|
139
|
+
}
|
|
140
|
+
static async abrir(navegador, url, pantalla = { ancho: 1280, alto: 800 }, preparar) {
|
|
141
|
+
const pagina = await navegador.newPage();
|
|
142
|
+
await pagina.setViewport({ width: pantalla.ancho, height: pantalla.alto });
|
|
143
|
+
if (preparar)
|
|
144
|
+
await preparar(pagina); // por ejemplo, un servidor simulado (exámenes)
|
|
145
|
+
const sesion = new SesionRender(pagina);
|
|
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); });
|
|
149
|
+
sesion.mapa = await escanearEstable(pagina);
|
|
150
|
+
await sesion.medirCarga();
|
|
151
|
+
return sesion;
|
|
152
|
+
}
|
|
153
|
+
async escanear() {
|
|
154
|
+
this.mapa = await escanear(this.pagina);
|
|
155
|
+
return this.mapa;
|
|
156
|
+
}
|
|
157
|
+
// El elemento real, en su marco (documento principal o el iframe de su molde, ver escaner.ts).
|
|
158
|
+
marcoDe(mapa, indice) {
|
|
159
|
+
const r = referenciaDe(mapa, indice);
|
|
160
|
+
if (!r || r.marco.detached)
|
|
161
|
+
throw new Error('the screen changed since the last scan: scan again');
|
|
162
|
+
return r;
|
|
163
|
+
}
|
|
164
|
+
async elemento(mapa, indice) {
|
|
165
|
+
const r = this.marcoDe(mapa, indice);
|
|
166
|
+
const h = await r.marco.evaluateHandle(`window.__renderRefs && window.__renderRefs[${r.indice}]`);
|
|
167
|
+
const el = h.asElement();
|
|
168
|
+
if (!el)
|
|
169
|
+
throw new Error('the element is no longer on the page');
|
|
170
|
+
return el;
|
|
171
|
+
}
|
|
172
|
+
/** Acción → escaneo → mapa nuevo → comparación con el anterior. */
|
|
173
|
+
async hacer(accion) {
|
|
174
|
+
const inicio = Date.now();
|
|
175
|
+
const antes = this.mapa ?? (await this.escanear());
|
|
176
|
+
this.red = [];
|
|
177
|
+
this.errores = [];
|
|
178
|
+
this.dialogos = [];
|
|
179
|
+
let objetivo = null;
|
|
180
|
+
try {
|
|
181
|
+
if ('objetivo' in accion) {
|
|
182
|
+
const i = resolver(antes, accion.objetivo);
|
|
183
|
+
if (i < 0) {
|
|
184
|
+
const hay = parecidos(antes, accion.objetivo);
|
|
185
|
+
throw new Error(`could not find "${accion.objetivo}" on the screen${hay.length ? `; on the screen there is: ${hay.join(', ')}` : ''}`);
|
|
186
|
+
}
|
|
187
|
+
objetivo = antes.elementos[i].id;
|
|
188
|
+
const el = await this.elemento(antes, i);
|
|
189
|
+
if (accion.tipo === 'clic')
|
|
190
|
+
await el.click();
|
|
191
|
+
else if (accion.tipo === 'llenar') {
|
|
192
|
+
await el.click({ count: 3 });
|
|
193
|
+
await this.pagina.keyboard.press('Backspace');
|
|
194
|
+
await el.type(accion.valor);
|
|
195
|
+
}
|
|
196
|
+
else if (accion.tipo === 'marcar') {
|
|
197
|
+
if (!!antes.elementos[i].estado?.marcado !== (accion.valor ?? true))
|
|
198
|
+
await el.click();
|
|
199
|
+
}
|
|
200
|
+
else if (accion.tipo === 'elegir') {
|
|
201
|
+
const elegido = await this.marcoDe(antes, i).marco.evaluate(`(function () {
|
|
202
|
+
var el = window.__renderRefs[${this.marcoDe(antes, i).indice}], v = ${JSON.stringify(accion.valor)}.toLowerCase();
|
|
203
|
+
for (var k = 0; k < el.options.length; k++) {
|
|
204
|
+
var o = el.options[k];
|
|
205
|
+
if (o.value.toLowerCase() === v || o.text.trim().toLowerCase() === v) { el.value = o.value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; }
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
})()`);
|
|
209
|
+
if (!elegido)
|
|
210
|
+
throw new Error(`"${accion.objetivo}" has no option "${accion.valor}"`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else if (accion.tipo === 'tecla')
|
|
214
|
+
await this.pagina.keyboard.press(accion.tecla);
|
|
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
|
+
}
|
|
220
|
+
// Que termine lo que la acción disparó (pedidos, animaciones cortas) antes de volver a escanear.
|
|
221
|
+
await this.pagina.waitForNetworkIdle({ idleTime: 300, timeout: 4_000 }).catch(() => { });
|
|
222
|
+
await new Promise((ok) => setTimeout(ok, 150));
|
|
223
|
+
const despues = await escanearEstable(this.pagina, { maxMs: 6_000 });
|
|
224
|
+
this.mapa = despues;
|
|
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();
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
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 };
|
|
230
|
+
}
|
|
231
|
+
return this.ultima;
|
|
232
|
+
}
|
|
233
|
+
/** El agente dice qué espera; la sesión contesta sí o no, con la prueba. */
|
|
234
|
+
verificar(expectativas) {
|
|
235
|
+
const mapa = this.mapa;
|
|
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;
|
|
239
|
+
return expectativas.map((x) => {
|
|
240
|
+
if (!mapa)
|
|
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 };
|
|
247
|
+
const buscar = (o) => { const i = resolver(mapa, o); return i >= 0 ? mapa.elementos[i] : null; };
|
|
248
|
+
if ('existe' in x) {
|
|
249
|
+
const e = buscar(x.existe);
|
|
250
|
+
return { expectativa: x, cumple: !!e, prueba: e ? describir(e) : `"${x.existe}" is not on the screen` };
|
|
251
|
+
}
|
|
252
|
+
if ('no_existe' in x) {
|
|
253
|
+
const e = buscar(x.no_existe);
|
|
254
|
+
return { expectativa: x, cumple: !e, prueba: e ? `it is there: ${describir(e)}` : 'not there' };
|
|
255
|
+
}
|
|
256
|
+
if ('texto' in x) {
|
|
257
|
+
const e = buscar(x.texto);
|
|
258
|
+
const dice = e ? [e.valor, e.texto, e.nombre].filter((v) => v !== undefined).join(' | ') : '';
|
|
259
|
+
return { expectativa: x, cumple: !!e && normal(dice).includes(normal(x.contiene)), prueba: e ? `${e.id} says "${dice}"` : `"${x.texto}" is not on the screen` };
|
|
260
|
+
}
|
|
261
|
+
if ('aparecio' in x || 'desaparecio' in x) {
|
|
262
|
+
const lista = 'aparecio' in x ? u?.cambio?.aparecieron : u?.cambio?.desaparecieron;
|
|
263
|
+
const buscado = normal('aparecio' in x ? x.aparecio : x.desaparecio);
|
|
264
|
+
const e = lista?.find((el) => el.id === ('aparecio' in x ? x.aparecio : x.desaparecio) || normal(el.nombre).includes(buscado) || normal(el.texto).includes(buscado));
|
|
265
|
+
return { expectativa: x, cumple: !!e, prueba: e ? describir(e) : `after the last action: ${u?.cambio?.resumen ?? 'no action yet'}` };
|
|
266
|
+
}
|
|
267
|
+
if ('sin_cambios' in x) {
|
|
268
|
+
const sin = !!u?.cambio?.sin_cambios;
|
|
269
|
+
return { expectativa: x, cumple: sin === x.sin_cambios, prueba: u?.cambio?.resumen ?? 'no action yet' };
|
|
270
|
+
}
|
|
271
|
+
if ('sin_fallas' in x) {
|
|
272
|
+
const fallas = mapa.fallas.filter((f) => x.sin_fallas === true || x.sin_fallas.includes(f.tipo));
|
|
273
|
+
return { expectativa: x, cumple: !fallas.length, prueba: fallas.length ? fallas.map((f) => `${f.tipo}${f.id ? ` en ${f.id}` : ''}: ${f.detalle}`).join('; ') : 'none' };
|
|
274
|
+
}
|
|
275
|
+
if ('sin_errores' in x) {
|
|
276
|
+
const err = u?.errores_pagina ?? [];
|
|
277
|
+
return { expectativa: x, cumple: !err.length, prueba: err.length ? err.join(' | ') : 'none' };
|
|
278
|
+
}
|
|
279
|
+
if ('pedido' in x) {
|
|
280
|
+
const p = x.pedido;
|
|
281
|
+
const coinciden = (u?.red ?? []).filter((r) => r.url.includes(p.url_contiene) && (!p.metodo || r.metodo === p.metodo.toUpperCase()));
|
|
282
|
+
const ok = coinciden.some((r) => p.status === undefined ? r.status < 400 : r.status === p.status);
|
|
283
|
+
return { expectativa: x, cumple: ok, prueba: coinciden.length ? coinciden.map((r) => `${r.metodo} ${r.url} → ${r.status}`).join('; ') : `no requests to "${p.url_contiene}"` };
|
|
284
|
+
}
|
|
285
|
+
return { expectativa: x, cumple: false, prueba: 'unknown expectation' };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async cerrar() { await this.pagina.close().catch(() => { }); }
|
|
289
|
+
}
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 🛰️ @meiti/render: servidor MCP por stdio (JSON-RPC 2.0) para que un agente pruebe una pantalla sin fotos ni coordenadas.
|
|
3
|
+
// Abre una sesión de navegador y la mantiene: cada acción es acción → escaneo → mapa nuevo → comparación con el anterior
|
|
4
|
+
// (idea del usuario: "como un GPS y un escáner; comparar el mapa 1 con el mapa 2 es ver"). El motor vive en dist/ (armado
|
|
5
|
+
// desde Healer, sanador/src/render). Textos para agentes en inglés, como todo lo que es para clientes.
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import puppeteer from 'puppeteer';
|
|
8
|
+
import { SesionRender, mapaEnTexto, rutasDeLaApp, recorrerApp, compararVersiones, resolver } from './dist/index.js';
|
|
9
|
+
|
|
10
|
+
const VERSION = '0.2.0';
|
|
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);
|
|
25
|
+
let navegador = null;
|
|
26
|
+
// Chrome: MEITI_RENDER_CHROME si se indica, el que descargó puppeteer, o el Chrome instalado en la máquina. Con npm 11+ los
|
|
27
|
+
// scripts de instalación pueden quedar bloqueados y puppeteer no descarga el suyo: entonces se usa el del sistema.
|
|
28
|
+
const ARGS = ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'];
|
|
29
|
+
async function lanzar() {
|
|
30
|
+
if (process.env.MEITI_RENDER_CHROME) return puppeteer.launch({ headless: true, args: ARGS, executablePath: process.env.MEITI_RENDER_CHROME });
|
|
31
|
+
try { return await puppeteer.launch({ headless: true, args: ARGS }); } catch (propio) {
|
|
32
|
+
try { return await puppeteer.launch({ headless: true, args: ARGS, channel: 'chrome' }); } catch {
|
|
33
|
+
throw new Error(`No Chrome found (${String(propio?.message || propio).split(String.fromCharCode(10))[0]}). Install one with "npx puppeteer browsers install chrome", or set MEITI_RENDER_CHROME to the path of a Chrome or Chromium executable.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const abrirNavegador = async () => (navegador ??= await lanzar());
|
|
38
|
+
|
|
39
|
+
const ACCIONES = { click: 'clic', fill: 'llenar', select: 'elegir', check: 'marcar', key: 'tecla', goto: 'ir' };
|
|
40
|
+
function accionInterna(a) {
|
|
41
|
+
const tipo = ACCIONES[a?.type];
|
|
42
|
+
if (!tipo) throw new Error('action.type is one of: click, fill, select, check, key, goto');
|
|
43
|
+
if (tipo === 'tecla') return { tipo, tecla: String(a.key || '') };
|
|
44
|
+
if (tipo === 'ir') return { tipo, url: String(a.url || '') };
|
|
45
|
+
if (!a.target) throw new Error('action.target is required (an id from the map, a label or a visible text)');
|
|
46
|
+
if (tipo === 'marcar') return { tipo, objetivo: String(a.target), valor: a.value === undefined ? true : !!a.value };
|
|
47
|
+
return { tipo, objetivo: String(a.target), ...(tipo === 'llenar' || tipo === 'elegir' ? { valor: String(a.value ?? '') } : {}) };
|
|
48
|
+
}
|
|
49
|
+
function expectativaInterna(x) {
|
|
50
|
+
if ('exists' in x) return { existe: String(x.exists) };
|
|
51
|
+
if ('not_exists' in x) return { no_existe: String(x.not_exists) };
|
|
52
|
+
if ('text' in x) return { texto: String(x.text), contiene: String(x.contains ?? '') };
|
|
53
|
+
if ('appeared' in x) return { aparecio: String(x.appeared) };
|
|
54
|
+
if ('disappeared' in x) return { desaparecio: String(x.disappeared) };
|
|
55
|
+
if ('no_changes' in x) return { sin_cambios: !!x.no_changes };
|
|
56
|
+
if ('no_issues' in x) return { sin_fallas: Array.isArray(x.no_issues) ? x.no_issues : true };
|
|
57
|
+
if ('no_errors' in x) return { sin_errores: true };
|
|
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 };
|
|
60
|
+
throw new Error(`Unknown expectation: ${JSON.stringify(x)}`);
|
|
61
|
+
}
|
|
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; };
|
|
63
|
+
const fallasEnTexto = (fallas) => (fallas.length ? fallas.map((f) => `- ${f.tipo}${f.id ? ` (${f.id})` : ''}: ${f.detalle}`).join('\n') : 'none');
|
|
64
|
+
|
|
65
|
+
const HERRAMIENTAS = [
|
|
66
|
+
{
|
|
67
|
+
name: 'render_open',
|
|
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.',
|
|
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
|
+
},
|
|
83
|
+
async run(a) {
|
|
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();
|
|
105
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
106
|
+
sesiones.set(id, sesion);
|
|
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');
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'render_act',
|
|
117
|
+
description: 'Do one action by INTENT (no coordinates) and return what changed on the screen: elements that appeared, disappeared, changed or moved (or "nothing changed": a control that does nothing), requests to the server with their status, page errors and native dialogs (accepted automatically). target is an id from the map (like "button:Save"), a label or a visible text; if it is not on the screen, the answer lists what is there.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
session: { type: 'string' },
|
|
122
|
+
action: { type: 'object', description: '{ "type": "click" | "fill" | "select" | "check" | "key" | "goto", "target": "...", "value": "...", "key": "Enter", "url": "..." }' },
|
|
123
|
+
include_map: { type: 'boolean', description: 'Also return the full new map. Default false (the change is usually enough).' },
|
|
124
|
+
},
|
|
125
|
+
required: ['session', 'action'],
|
|
126
|
+
},
|
|
127
|
+
async run(a) {
|
|
128
|
+
const sesion = sesionDe(a.session);
|
|
129
|
+
const r = await sesion.hacer(accionInterna(a.action));
|
|
130
|
+
if (!r.ok) return `Could not do it: ${r.error}`;
|
|
131
|
+
const red = r.red.filter((p) => !/\.(css|js|png|jpe?g|svg|woff2?|ico)(\?|$)/.test(p.url));
|
|
132
|
+
return [
|
|
133
|
+
`Acted on ${r.objetivo ?? '(page)'} in ${r.ms} ms.`,
|
|
134
|
+
`What changed: ${r.cambio?.resumen}`,
|
|
135
|
+
sesion.estado && sesion.estado.veredicto !== 'cargada' ? estadoEnTexto(sesion.estado) : '',
|
|
136
|
+
red.length ? `Requests:\n${red.map((p) => `- ${p.metodo} ${p.url} → ${p.status}`).join('\n')}` : 'Requests: none',
|
|
137
|
+
`Page errors: ${r.errores_pagina.length ? r.errores_pagina.join(' | ') : 'none'}`,
|
|
138
|
+
r.dialogos.length ? `Native dialogs (accepted): ${r.dialogos.join(' | ')}` : '',
|
|
139
|
+
r.cambio?.fallas_nuevas.length ? `New visual issues:\n${fallasEnTexto(r.cambio.fallas_nuevas)}` : '',
|
|
140
|
+
a.include_map ? `\n${mapaEnTexto(sesion.mapa)}` : '',
|
|
141
|
+
].filter(Boolean).join('\n');
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'render_expect',
|
|
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.',
|
|
147
|
+
inputSchema: { type: 'object', properties: { session: { type: 'string' }, expectations: { type: 'array', items: { type: 'object' } } }, required: ['session', 'expectations'] },
|
|
148
|
+
async run(a) {
|
|
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
|
+
});
|
|
156
|
+
// Se devuelve la expectativa tal como la mandó el agente (no la forma interna).
|
|
157
|
+
return resultados.map((r, i) => `${r.sin_prueba ? 'CANNOT PROVE' : r.cumple ? 'YES' : 'NO '} ${JSON.stringify(a.expectations[i])} · ${r.prueba}`).join('\n');
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'render_scan',
|
|
162
|
+
description: 'Scan the current screen again (waits until it is stable) and return the full map with its visual issues.',
|
|
163
|
+
inputSchema: { type: 'object', properties: { session: { type: 'string' } }, required: ['session'] },
|
|
164
|
+
async run(a) { const s = sesionDe(a.session); await s.escanear(); await s.medirCarga(); return `${estadoEnTexto(s.estado)}\n\n${mapaEnTexto(s.mapa)}`; },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'render_compare',
|
|
168
|
+
description: 'Compare two versions of the same app (for example before and after a change): the home page and up to 3 pages it links to, on a phone and a desktop. Says WORSE if a visual issue appears, an interactive element disappears or a page stops opening; BETTER if issues were fixed; SAME otherwise.',
|
|
169
|
+
inputSchema: { type: 'object', properties: { url_before: { type: 'string' }, url_after: { type: 'string' } }, required: ['url_before', 'url_after'] },
|
|
170
|
+
async run(a) {
|
|
171
|
+
const pagina = await (await abrirNavegador()).newPage();
|
|
172
|
+
try {
|
|
173
|
+
const rutas = await rutasDeLaApp(pagina, String(a.url_after)).catch(() => ['/']);
|
|
174
|
+
const despues = await recorrerApp(pagina, String(a.url_after), rutas);
|
|
175
|
+
const antes = await recorrerApp(pagina, String(a.url_before), rutas);
|
|
176
|
+
const r = compararVersiones(antes, despues);
|
|
177
|
+
const etiqueta = { peor: 'WORSE', mejor: 'BETTER', igual: 'SAME', sin_datos: 'NO DATA' }[r.veredicto];
|
|
178
|
+
return [`${etiqueta}: ${r.resumen}`, `Pages: ${rutas.join(', ')} (${r.pantallas} screens). Visual issues before ${r.fallas_antes}, after ${r.fallas_despues}.`,
|
|
179
|
+
...r.regresiones.map((x) => `- ${x.tipo} · ${x.pantalla} · ${x.detalle}`), ...r.mejoras.map((m) => `+ fixed · ${m}`)].join('\n');
|
|
180
|
+
} finally { await pagina.close().catch(() => {}); }
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'render_close',
|
|
185
|
+
description: 'Close a session.',
|
|
186
|
+
inputSchema: { type: 'object', properties: { session: { type: 'string' } }, required: ['session'] },
|
|
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
|
+
},
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const responder = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
199
|
+
const fallar = (id, code, message) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
|
|
200
|
+
const cerrarTodo = async () => { if (navegador) await navegador.close().catch(() => {}); process.exit(0); };
|
|
201
|
+
|
|
202
|
+
const lector = readline.createInterface({ input: process.stdin });
|
|
203
|
+
lector.on('close', cerrarTodo);
|
|
204
|
+
lector.on('line', async (linea) => {
|
|
205
|
+
let m;
|
|
206
|
+
try { m = JSON.parse(linea); } catch { return; }
|
|
207
|
+
if (m.id === undefined) return;
|
|
208
|
+
try {
|
|
209
|
+
if (m.method === 'initialize') return responder(m.id, { protocolVersion: m.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'meiti-render', title: 'MEITI Render', version: VERSION, websiteUrl: 'https://meiti.dev/agents' }, instructions: 'Test screens like an agent, not like a person: render_open returns a map of the screen; render_act does one action by intent and tells you what changed; render_expect checks what you expect. Compare maps instead of looking at screenshots.' });
|
|
210
|
+
if (m.method === 'ping') return responder(m.id, {});
|
|
211
|
+
if (m.method === 'tools/list') return responder(m.id, { tools: HERRAMIENTAS.map(({ run, ...h }) => h) });
|
|
212
|
+
if (m.method === 'tools/call') {
|
|
213
|
+
const h = HERRAMIENTAS.find((x) => x.name === m.params?.name);
|
|
214
|
+
if (!h) return fallar(m.id, -32602, `Unknown tool: ${m.params?.name}`);
|
|
215
|
+
try { return responder(m.id, { content: [{ type: 'text', text: await h.run(m.params?.arguments || {}) }] }); }
|
|
216
|
+
catch (e) { return responder(m.id, { content: [{ type: 'text', text: String(e?.message || e) }], isError: true }); }
|
|
217
|
+
}
|
|
218
|
+
return fallar(m.id, -32601, `Method not supported: ${m.method}`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return fallar(m.id, -32603, String(e?.message || e));
|
|
221
|
+
}
|
|
222
|
+
});
|