agents-city 0.3.0-beta.21 → 0.3.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/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +310 -70
- package/README.md +297 -69
- package/bin/agents-city.js +3 -0
- package/bin/doctor +3 -0
- package/bin/hall.html +164 -24
- package/bin/navegador.mjs +415 -0
- package/bin/serve.py +383 -127
- package/bin/shortcut +3 -0
- package/bin/test +5 -2
- package/bin/test-actualiza.py +130 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-busca.py +216 -0
- package/bin/test-cage.py +170 -2
- package/bin/test-card.py +2 -2
- package/bin/test-cities.py +45 -0
- package/bin/test-contracts.py +12 -5
- package/bin/test-doctor.py +33 -0
- package/bin/test-navegador.py +164 -0
- package/bin/test-seat.py +245 -25
- package/bin/test-serve.py +214 -9
- package/bin/test-workspace.py +63 -0
- package/bin/testlib.py +23 -0
- package/bin/update +3 -0
- package/city/web/dist/city.js +47 -47
- package/city/web/dist/index.html +1 -1
- package/city/web/dist-hall/hall.js +2193 -174
- package/city/web/src/bienvenida.ts +686 -0
- package/city/web/src/es.ts +180 -0
- package/city/web/src/hall.ts +520 -168
- package/city/web/src/idioma.ts +86 -0
- package/city/web/src/main.ts +27 -0
- package/city/web/src/motores.ts +54 -0
- package/docs/agents-first.md +8 -1
- package/docs/security.md +46 -12
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/busca.py +436 -0
- package/plugin/scripts/cage.py +266 -26
- package/plugin/scripts/capabilities.py +17 -10
- package/plugin/scripts/card.py +10 -0
- package/plugin/scripts/cities.py +34 -0
- package/plugin/scripts/city-session.sh +33 -7
- package/plugin/scripts/doctor.py +122 -0
- package/plugin/scripts/find-repos.sh +12 -105
- package/plugin/scripts/read-card.py +6 -2
- package/plugin/scripts/report.py +5 -6
- package/plugin/scripts/reset.py +50 -14
- package/plugin/scripts/seat.py +445 -103
- package/plugin/scripts/workspace.py +197 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Drive a real Chrome against a running Hall, over the DevTools protocol.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: every other suite in this repo can pass while the page is
|
|
5
|
+
* dead. Instruction editors and a zip upload once shipped rendered, typechecked
|
|
6
|
+
* and fully tested on the server side — and wired to nothing. `tsc` cannot see
|
|
7
|
+
* an event handler that was never attached, and neither can a DOM-free test.
|
|
8
|
+
* Only a browser that clicks can.
|
|
9
|
+
*
|
|
10
|
+
* No dependency: Node 22 has a global WebSocket, and CDP is JSON over one. A
|
|
11
|
+
* headless browser is heavy enough without a driver library on top.
|
|
12
|
+
*
|
|
13
|
+
* node bin/navegador.mjs <url>
|
|
14
|
+
*
|
|
15
|
+
* Prints one line per check and exits non-zero if any failed.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const CANDIDATOS = [
|
|
24
|
+
process.env.CHROME_PATH,
|
|
25
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
26
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
27
|
+
'/usr/bin/google-chrome',
|
|
28
|
+
'/usr/bin/google-chrome-stable',
|
|
29
|
+
'/usr/bin/chromium',
|
|
30
|
+
'/usr/bin/chromium-browser',
|
|
31
|
+
'/snap/bin/chromium',
|
|
32
|
+
].filter(Boolean);
|
|
33
|
+
|
|
34
|
+
function dondeEstaChrome() {
|
|
35
|
+
return CANDIDATOS.find((p) => existsSync(p)) || '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Chrome prints its DevTools endpoint on stderr; that is the handshake. */
|
|
39
|
+
function esperaEndpoint(proceso, limite = 20000) {
|
|
40
|
+
return new Promise((listo, falla) => {
|
|
41
|
+
let visto = '';
|
|
42
|
+
const reloj = setTimeout(() => falla(new Error('chrome never announced its port')), limite);
|
|
43
|
+
proceso.stderr.on('data', (trozo) => {
|
|
44
|
+
visto += trozo.toString();
|
|
45
|
+
const m = visto.match(/ws:\/\/[^\s]+/);
|
|
46
|
+
if (m) {
|
|
47
|
+
clearTimeout(reloj);
|
|
48
|
+
listo(m[0]);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
proceso.on('exit', (codigo) => {
|
|
52
|
+
clearTimeout(reloj);
|
|
53
|
+
falla(new Error(`chrome exited with ${codigo}: ${visto.slice(-400)}`));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class Cdp {
|
|
59
|
+
constructor(socket) {
|
|
60
|
+
this.socket = socket;
|
|
61
|
+
this.n = 0;
|
|
62
|
+
this.pendientes = new Map();
|
|
63
|
+
socket.addEventListener('message', (evento) => {
|
|
64
|
+
const mensaje = JSON.parse(evento.data);
|
|
65
|
+
const espera = this.pendientes.get(mensaje.id);
|
|
66
|
+
if (!espera) return;
|
|
67
|
+
this.pendientes.delete(mensaje.id);
|
|
68
|
+
mensaje.error ? espera.falla(new Error(JSON.stringify(mensaje.error))) : espera.listo(mensaje.result);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static async abre(url) {
|
|
73
|
+
const socket = new WebSocket(url);
|
|
74
|
+
await new Promise((listo, falla) => {
|
|
75
|
+
socket.addEventListener('open', listo, { once: true });
|
|
76
|
+
socket.addEventListener('error', () => falla(new Error('cannot reach chrome')), { once: true });
|
|
77
|
+
});
|
|
78
|
+
return new Cdp(socket);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
manda(metodo, params = {}) {
|
|
82
|
+
const id = ++this.n;
|
|
83
|
+
this.socket.send(JSON.stringify({ id, method: metodo, params }));
|
|
84
|
+
return new Promise((listo, falla) => this.pendientes.set(id, { listo, falla }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Evaluate in the page and return the value, awaiting promises. */
|
|
88
|
+
async evalua(expresion) {
|
|
89
|
+
const r = await this.manda('Runtime.evaluate', {
|
|
90
|
+
expression: expresion,
|
|
91
|
+
awaitPromise: true,
|
|
92
|
+
returnByValue: true,
|
|
93
|
+
});
|
|
94
|
+
if (r.exceptionDetails) {
|
|
95
|
+
throw new Error(r.exceptionDetails.exception?.description ?? 'page threw');
|
|
96
|
+
}
|
|
97
|
+
return r.result.value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
102
|
+
|
|
103
|
+
let fallos = 0;
|
|
104
|
+
function comprueba(texto, bien, detalle = '') {
|
|
105
|
+
console.log(`${bien ? ' ok ·' : ' FAIL·'} ${texto}${bien || !detalle ? '' : `\n ${detalle}`}`);
|
|
106
|
+
if (!bien) fallos += 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
const url = process.argv[2];
|
|
111
|
+
const ciudadVacia = process.argv[3] || '';
|
|
112
|
+
if (!url) {
|
|
113
|
+
console.error('usage: navegador.mjs <hall url>');
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
const chrome = dondeEstaChrome();
|
|
117
|
+
if (!chrome) {
|
|
118
|
+
if (process.env.CITY_BROWSER_REQUIRED === '1') {
|
|
119
|
+
console.error(' no Chrome on this machine, and CITY_BROWSER_REQUIRED=1');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
console.log(' no Chrome here — browser checks skipped');
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
const perfil = await mkdtemp(join(tmpdir(), 'agents-city-chrome-'));
|
|
126
|
+
const proceso = spawn(chrome, [
|
|
127
|
+
'--headless=new',
|
|
128
|
+
'--remote-debugging-port=0',
|
|
129
|
+
'--no-first-run',
|
|
130
|
+
'--no-default-browser-check',
|
|
131
|
+
'--disable-gpu',
|
|
132
|
+
'--disable-dev-shm-usage',
|
|
133
|
+
`--user-data-dir=${perfil}`,
|
|
134
|
+
'about:blank',
|
|
135
|
+
]);
|
|
136
|
+
try {
|
|
137
|
+
// Talk to the PAGE's own socket rather than the browser's: no sessions to
|
|
138
|
+
// thread through every message, which is where a hand-rolled driver goes
|
|
139
|
+
// wrong first.
|
|
140
|
+
const endpoint = await esperaEndpoint(proceso);
|
|
141
|
+
const base = new URL(endpoint.replace(/^ws/, 'http'));
|
|
142
|
+
const pestanas = await (await fetch(`http://${base.host}/json/list`)).json();
|
|
143
|
+
const pagina = pestanas.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
144
|
+
if (!pagina) throw new Error('chrome opened no page to drive');
|
|
145
|
+
const cdp = await Cdp.abre(pagina.webSocketDebuggerUrl);
|
|
146
|
+
|
|
147
|
+
await cdp.manda('Page.enable');
|
|
148
|
+
await cdp.manda('Runtime.enable');
|
|
149
|
+
await cdp.manda('Page.navigate', { url });
|
|
150
|
+
for (let i = 0; i < 60; i += 1) {
|
|
151
|
+
const listo = await cdp.evalua('!!document.getElementById("rail") && !!window.PASE');
|
|
152
|
+
if (listo) break;
|
|
153
|
+
await dormir(250);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
comprueba(
|
|
157
|
+
'the hall loads and reads its city',
|
|
158
|
+
await cdp.evalua(
|
|
159
|
+
'(async () => { for (let i = 0; i < 40; i++) { if (document.querySelectorAll("nav li").length) return true; await new Promise(r => setTimeout(r, 250)); } return false; })()',
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// The skin, both ways, and remembered.
|
|
164
|
+
const tema = await cdp.evalua(`(() => {
|
|
165
|
+
const b = document.getElementById('temaBoton');
|
|
166
|
+
if (!b) return { falta: true };
|
|
167
|
+
const antes = document.documentElement.getAttribute('data-tema');
|
|
168
|
+
b.click();
|
|
169
|
+
const despues = document.documentElement.getAttribute('data-tema');
|
|
170
|
+
return { antes, despues, guardado: localStorage.getItem('hall-tema'), etiqueta: b.textContent.trim() };
|
|
171
|
+
})()`);
|
|
172
|
+
comprueba(
|
|
173
|
+
'the day/night switch flips the page and remembers the choice',
|
|
174
|
+
!!tema && !tema.falta && tema.despues && tema.guardado === tema.despues,
|
|
175
|
+
JSON.stringify(tema),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// The agents view: this is where dead controls shipped once.
|
|
179
|
+
const abierto = await cdp.evalua(`(async () => {
|
|
180
|
+
const ir = [...document.querySelectorAll('nav li')].find(l => /houses/i.test(l.textContent));
|
|
181
|
+
if (!ir) return { falta: 'no houses entry' };
|
|
182
|
+
ir.click();
|
|
183
|
+
for (let i = 0; i < 40; i++) {
|
|
184
|
+
if (document.querySelector('.fichaRPG')) break;
|
|
185
|
+
await new Promise(r => setTimeout(r, 250));
|
|
186
|
+
}
|
|
187
|
+
const boton = (sel) => { const e = document.querySelector(sel); return e ? !!(e.onclick || e.onchange) : null; };
|
|
188
|
+
return {
|
|
189
|
+
fichas: document.querySelectorAll('.fichaRPG').length,
|
|
190
|
+
alta: boton('#altaAgente'),
|
|
191
|
+
instrucciones: boton('.rpgIns'),
|
|
192
|
+
subir: boton('.rpgSubir input'),
|
|
193
|
+
monta: boton('.rpgMonta'),
|
|
194
|
+
dado: boton('.rpgDado'),
|
|
195
|
+
test: boton('.rpgTest'),
|
|
196
|
+
};
|
|
197
|
+
})()`);
|
|
198
|
+
comprueba('the houses view renders its sheets', (abierto?.fichas ?? 0) > 0, JSON.stringify(abierto));
|
|
199
|
+
for (const [control, etiqueta] of [
|
|
200
|
+
['alta', 'the build-a-house button'],
|
|
201
|
+
['instrucciones', 'the CLAUDE.md / AGENTS.md editors'],
|
|
202
|
+
['subir', 'the skill zip upload'],
|
|
203
|
+
['monta', 'the + folder mount button'],
|
|
204
|
+
['dado', 'the avatar reroll'],
|
|
205
|
+
['test', 'the engine test button'],
|
|
206
|
+
]) {
|
|
207
|
+
comprueba(
|
|
208
|
+
`${etiqueta} is wired to a handler, not just drawn`,
|
|
209
|
+
abierto?.[control] === true,
|
|
210
|
+
`${control}=${JSON.stringify(abierto?.[control])}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// And one of them actually does something when clicked.
|
|
215
|
+
const modal = await cdp.evalua(`(async () => {
|
|
216
|
+
const b = document.querySelector('.rpgIns');
|
|
217
|
+
if (!b) return { falta: true };
|
|
218
|
+
b.click();
|
|
219
|
+
for (let i = 0; i < 40; i++) {
|
|
220
|
+
const m = document.getElementById('editorIns');
|
|
221
|
+
if (m && !m.hidden) return { abierto: true, titulo: document.getElementById('edTitulo')?.textContent ?? '' };
|
|
222
|
+
await new Promise(r => setTimeout(r, 250));
|
|
223
|
+
}
|
|
224
|
+
return { abierto: false };
|
|
225
|
+
})()`);
|
|
226
|
+
comprueba(
|
|
227
|
+
'clicking CLAUDE.md opens the editor on that agent',
|
|
228
|
+
modal?.abierto === true && /CLAUDE\.md/.test(modal.titulo ?? ''),
|
|
229
|
+
JSON.stringify(modal),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// The guided first run, checked against a city with nobody in it: that is
|
|
233
|
+
// the only state where it appears, and it is the screen that used to say
|
|
234
|
+
// "Not drawn yet" to somebody arriving for the first time.
|
|
235
|
+
if (ciudadVacia) {
|
|
236
|
+
await cdp.manda('Page.navigate', {
|
|
237
|
+
url: url + '&city=' + encodeURIComponent(ciudadVacia),
|
|
238
|
+
});
|
|
239
|
+
for (let i = 0; i < 60; i += 1) {
|
|
240
|
+
const listo = await cdp.evalua('!!document.getElementById("rail") && !!window.PASE');
|
|
241
|
+
if (listo) break;
|
|
242
|
+
await dormir(250);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const guia = await cdp.evalua(`(async () => {
|
|
246
|
+
for (let i = 0; i < 40; i++) {
|
|
247
|
+
if (document.querySelector('.bvPasos')) break;
|
|
248
|
+
await new Promise(r => setTimeout(r, 250));
|
|
249
|
+
}
|
|
250
|
+
const pasos = [...document.querySelectorAll('.bvPunto')].map(p => p.textContent.trim());
|
|
251
|
+
const b = document.querySelector('[data-bv="siguiente"]');
|
|
252
|
+
if (b) b.click();
|
|
253
|
+
await new Promise(r => setTimeout(r, 900));
|
|
254
|
+
return {
|
|
255
|
+
pasos,
|
|
256
|
+
titulo: document.querySelector('.bvPaso h1')?.textContent?.trim() ?? '',
|
|
257
|
+
opciones: document.querySelectorAll('.bvOpcion').length,
|
|
258
|
+
};
|
|
259
|
+
})()`);
|
|
260
|
+
comprueba(
|
|
261
|
+
'a city with no agents opens the guide, not an empty map',
|
|
262
|
+
(guia?.pasos?.length ?? 0) >= 4,
|
|
263
|
+
JSON.stringify(guia),
|
|
264
|
+
);
|
|
265
|
+
comprueba(
|
|
266
|
+
'and its first question offers the work domains to choose from',
|
|
267
|
+
/work happens here/i.test(guia?.titulo ?? '') && (guia?.opciones ?? 0) > 3,
|
|
268
|
+
JSON.stringify(guia),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// The house form: the disk search and the three engine questions. Both
|
|
272
|
+
// shipped missing once — the picker offered twelve repositories with no way
|
|
273
|
+
// to search past them, and nowhere to say what runs the agent, so somebody
|
|
274
|
+
// building five houses had to go and set all of it again afterwards.
|
|
275
|
+
const casa = await cdp.evalua(`(async () => {
|
|
276
|
+
const espera = async (sel, n = 40) => {
|
|
277
|
+
for (let i = 0; i < n; i++) {
|
|
278
|
+
const el = document.querySelector(sel);
|
|
279
|
+
if (el) return el;
|
|
280
|
+
await new Promise(r => setTimeout(r, 250));
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
};
|
|
284
|
+
// domain -> next -> role -> next -> the roster question
|
|
285
|
+
(document.querySelector('.bvOpcion'))?.click();
|
|
286
|
+
await new Promise(r => setTimeout(r, 200));
|
|
287
|
+
document.querySelector('[data-bv="siguiente"]')?.click();
|
|
288
|
+
await new Promise(r => setTimeout(r, 700));
|
|
289
|
+
(await espera('.bvOpcion'))?.click();
|
|
290
|
+
await new Promise(r => setTimeout(r, 200));
|
|
291
|
+
document.querySelector('[data-bv="siguiente"]')?.click();
|
|
292
|
+
await new Promise(r => setTimeout(r, 900));
|
|
293
|
+
(await espera('[data-bv="nuevo"]'))?.click();
|
|
294
|
+
await new Promise(r => setTimeout(r, 400));
|
|
295
|
+
const busca = await espera('#bvBusca');
|
|
296
|
+
const motores = ['#bvRuntime', '#bvModelo', '#bvEsfuerzo'].map(s => !!document.querySelector(s));
|
|
297
|
+
const opcionesMotor = [...(document.querySelector('#bvModelo')?.options ?? [])].map(o => o.value);
|
|
298
|
+
const antes = document.querySelectorAll('.bvChip').length;
|
|
299
|
+
let despues = antes;
|
|
300
|
+
if (busca) {
|
|
301
|
+
busca.value = 'zzz-no-existe-nada';
|
|
302
|
+
busca.dispatchEvent(new Event('input', { bubbles: true }));
|
|
303
|
+
await new Promise(r => setTimeout(r, 400));
|
|
304
|
+
despues = document.querySelectorAll('.bvChip').length;
|
|
305
|
+
}
|
|
306
|
+
return { hayBusca: !!busca, motores, opcionesMotor, antes, despues,
|
|
307
|
+
rebusca: !!document.querySelector('[data-bv="rebusca"]') };
|
|
308
|
+
})()`);
|
|
309
|
+
comprueba(
|
|
310
|
+
'the house form searches the whole disk instead of offering a dozen repos',
|
|
311
|
+
!!casa?.hayBusca && !!casa?.rebusca && casa.antes > 0 && casa.despues === 0,
|
|
312
|
+
JSON.stringify(casa),
|
|
313
|
+
);
|
|
314
|
+
comprueba(
|
|
315
|
+
'and asks what runs the agent — provider, engine and effort',
|
|
316
|
+
(casa?.motores ?? []).every(Boolean) && (casa?.opcionesMotor ?? []).includes('opus'),
|
|
317
|
+
JSON.stringify(casa),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Cities: a person must be able to start and retire one without a terminal.
|
|
321
|
+
const ciudades = await cdp.evalua(`(async () => {
|
|
322
|
+
const ir = [...document.querySelectorAll('nav li')].find(l => /cities/i.test(l.textContent));
|
|
323
|
+
if (!ir) return { falta: true };
|
|
324
|
+
ir.click();
|
|
325
|
+
await new Promise(r => setTimeout(r, 700));
|
|
326
|
+
return {
|
|
327
|
+
crear: !!document.getElementById('creaCiudad'),
|
|
328
|
+
campo: !!document.getElementById('nuevaCiudad'),
|
|
329
|
+
filas: document.querySelectorAll('.lista .fila').length,
|
|
330
|
+
};
|
|
331
|
+
})()`);
|
|
332
|
+
const reinicio = await cdp.evalua(`(async () => {
|
|
333
|
+
const b = document.getElementById('reiniciaCiudad');
|
|
334
|
+
if (!b) return { falta: true };
|
|
335
|
+
// Nothing is clicked here: this asks the server what a reset WOULD do,
|
|
336
|
+
// which is the same call the button makes before it dares ask anything.
|
|
337
|
+
const r = await fetch('/api/ciudad-reinicia?PASE=' + window.PASE, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'X-City-Pase': window.PASE, 'Content-Type': 'application/json' },
|
|
340
|
+
body: '{}',
|
|
341
|
+
}).then((x) => x.json());
|
|
342
|
+
return { cableado: !!b.onclick, ok: r.ok, previa: r.preview, error: r.error };
|
|
343
|
+
})()`);
|
|
344
|
+
comprueba(
|
|
345
|
+
'the danger button exists and is wired',
|
|
346
|
+
reinicio?.cableado === true,
|
|
347
|
+
JSON.stringify(reinicio),
|
|
348
|
+
);
|
|
349
|
+
comprueba(
|
|
350
|
+
// Two acceptable answers, one forbidden: a preview of what would happen,
|
|
351
|
+
// or a refusal (this fixture's city lives outside the owner's folder and
|
|
352
|
+
// reset declines to touch such a thing). What must NEVER come back from a
|
|
353
|
+
// call with no typed name is ok:true — that would be a reset on one click.
|
|
354
|
+
'without the typed name it previews or refuses, but never resets',
|
|
355
|
+
reinicio?.ok !== true &&
|
|
356
|
+
(Array.isArray(reinicio?.previa?.loses) || typeof reinicio?.error === 'string'),
|
|
357
|
+
JSON.stringify(reinicio),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
comprueba(
|
|
361
|
+
'the Cities section can start a new city',
|
|
362
|
+
ciudades?.crear === true && ciudades?.campo === true,
|
|
363
|
+
JSON.stringify(ciudades),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Spanish and English, on a switch. The READMEs shipped bilingual and the
|
|
367
|
+
// product did not; a language button that does not actually change the page
|
|
368
|
+
// is worse than none at all.
|
|
369
|
+
const lengua = await cdp.evalua(`(async () => {
|
|
370
|
+
const b = document.getElementById('idiomaBoton');
|
|
371
|
+
if (!b) return { falta: true };
|
|
372
|
+
const rail = () => document.getElementById('rail')?.textContent ?? '';
|
|
373
|
+
const antes = rail();
|
|
374
|
+
const etiquetaAntes = b.textContent.trim();
|
|
375
|
+
b.click();
|
|
376
|
+
for (let i = 0; i < 40; i++) {
|
|
377
|
+
if (rail() !== antes) break;
|
|
378
|
+
await new Promise(r => setTimeout(r, 250));
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
antes,
|
|
382
|
+
despues: rail(),
|
|
383
|
+
etiquetaAntes,
|
|
384
|
+
etiqueta: b.textContent.trim(),
|
|
385
|
+
guardado: localStorage.getItem('hall-idioma'),
|
|
386
|
+
lang: document.documentElement.lang,
|
|
387
|
+
marca: document.querySelector('.marca p')?.textContent?.trim() ?? '',
|
|
388
|
+
};
|
|
389
|
+
})()`);
|
|
390
|
+
comprueba(
|
|
391
|
+
'the language switch really translates the page, and remembers it',
|
|
392
|
+
!!lengua && !lengua.falta && lengua.despues !== lengua.antes &&
|
|
393
|
+
/casas|resumen|mapa/i.test(lengua.despues) && lengua.guardado === lengua.lang &&
|
|
394
|
+
lengua.etiqueta !== lengua.etiquetaAntes,
|
|
395
|
+
JSON.stringify(lengua),
|
|
396
|
+
);
|
|
397
|
+
comprueba(
|
|
398
|
+
'including the chrome that lives in the page rather than in a view',
|
|
399
|
+
/ciudad|ayuntamiento/i.test(lengua?.marca ?? ''),
|
|
400
|
+
JSON.stringify(lengua?.marca),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const errores = await cdp.evalua('window.__erroresDePagina ? window.__erroresDePagina.length : 0');
|
|
404
|
+
comprueba('the page raised no uncaught errors while we drove it', errores === 0, String(errores));
|
|
405
|
+
} finally {
|
|
406
|
+
proceso.kill('SIGKILL');
|
|
407
|
+
}
|
|
408
|
+
console.log(`\n ${fallos ? `${fallos} failed` : 'browser ok'} — 16 checks\n`);
|
|
409
|
+
process.exit(fallos ? 1 : 0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main().catch((e) => {
|
|
413
|
+
console.error(` browser checks could not run: ${e.message}`);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
});
|