@danieltmn/tamnora 0.21.2 → 0.23.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/CHANGELOG.md +28 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +7 -1
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +7 -1
- package/harness/packages/server/public/app.js +347 -6757
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -0
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +813 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +40 -41
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1080 -0
- package/harness/packages/server/public/styles.css +45 -52
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -932
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +12 -2
- package/harness/packages/server/src/lib/tasks.js +21 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +16 -5
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +14 -9
- package/harness/packages/server/src/server.js +25 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +121 -0
- package/harness/packages/server/test/ops.test.js +515 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +482 -0
- package/harness/scripts/canary.mjs +24 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +3 -2
- package/harness/packages/server/test/server.test.js +0 -2885
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
ADVISOR_KINDS,
|
|
5
|
+
advisorMeta,
|
|
6
|
+
basename,
|
|
7
|
+
formatDuration,
|
|
8
|
+
isFailedToolText,
|
|
9
|
+
isReadOnlyTool,
|
|
10
|
+
parseToolArgs,
|
|
11
|
+
previewText,
|
|
12
|
+
screenshotName,
|
|
13
|
+
shortText,
|
|
14
|
+
toolSummary,
|
|
15
|
+
userImages,
|
|
16
|
+
userText,
|
|
17
|
+
} from '../public/chatFormat.js';
|
|
18
|
+
|
|
19
|
+
test('shortText normaliza espacios y corta con punto', () => {
|
|
20
|
+
assert.equal(shortText(' hola mundo '), 'hola mundo');
|
|
21
|
+
assert.equal(shortText('x'.repeat(10), 5), 'xxxx…');
|
|
22
|
+
assert.equal(shortText(null), '');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('isReadOnlyTool distingue las tools de lectura, tambien las MCP', () => {
|
|
26
|
+
assert.ok(isReadOnlyTool('read'));
|
|
27
|
+
assert.ok(isReadOnlyTool('grep'));
|
|
28
|
+
assert.ok(!isReadOnlyTool('write'));
|
|
29
|
+
assert.ok(!isReadOnlyTool('bash'));
|
|
30
|
+
assert.ok(isReadOnlyTool('mcp__postgres__query'));
|
|
31
|
+
assert.ok(isReadOnlyTool('mcp__fs__list_files'));
|
|
32
|
+
assert.ok(!isReadOnlyTool('mcp__fs__write_file'));
|
|
33
|
+
assert.ok(!isReadOnlyTool(undefined));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('toolSummary resume cada tool en una linea corta', () => {
|
|
37
|
+
assert.equal(toolSummary('read', { path: 'src/a.js', offset: 10, limit: 5 }), 'src/a.js:10-14');
|
|
38
|
+
assert.equal(toolSummary('read', { path: 'src/a.js', offset: 10 }), 'src/a.js:10+');
|
|
39
|
+
assert.equal(toolSummary('read', {}), '.');
|
|
40
|
+
assert.equal(toolSummary('write', { path: 'a.txt', content: 'x' }), 'a.txt');
|
|
41
|
+
assert.equal(toolSummary('bash', { command: 'npm test\notra linea' }), 'npm test');
|
|
42
|
+
assert.equal(toolSummary('grep', { pattern: 'foo', path: 'src' }), '"foo" en src');
|
|
43
|
+
assert.equal(toolSummary('git_diff', { staged: true, path: 'a.js' }), 'staged a.js');
|
|
44
|
+
assert.equal(toolSummary('git_log', { limit: 5 }), '5 commits');
|
|
45
|
+
assert.equal(toolSummary('desconocida', {}), '');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('parseToolArgs acepta objeto, JSON y basura', () => {
|
|
49
|
+
assert.deepEqual(parseToolArgs({ function: { arguments: '{"a":1}' } }), { a: 1 });
|
|
50
|
+
assert.deepEqual(parseToolArgs({ arguments: { a: 2 } }), { a: 2 });
|
|
51
|
+
assert.deepEqual(parseToolArgs({ arguments: 'no es json' }), {});
|
|
52
|
+
assert.deepEqual(parseToolArgs(null), {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('isFailedToolText detecta errores y permisos denegados', () => {
|
|
56
|
+
assert.ok(isFailedToolText('Error: se rompio'));
|
|
57
|
+
assert.ok(isFailedToolText('Permiso denegado por el usuario'));
|
|
58
|
+
assert.ok(!isFailedToolText('listo, sin errores'));
|
|
59
|
+
assert.ok(!isFailedToolText(undefined));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('screenshotName sale del meta o del texto del resultado', () => {
|
|
63
|
+
assert.equal(screenshotName({ meta: { screenshot: { name: 'a.png' } } }, ''), 'a.png');
|
|
64
|
+
assert.equal(screenshotName({}, 'guardado en capturas/abc-123.png'), 'abc-123.png');
|
|
65
|
+
assert.equal(screenshotName({}, 'sin captura'), null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('previewText trunca por lineas y por caracteres', () => {
|
|
69
|
+
const largo = Array.from({ length: 50 }, (_, i) => `linea ${i}`).join('\n');
|
|
70
|
+
const { preview, truncated } = previewText(largo, 5, 100);
|
|
71
|
+
assert.equal(preview.split('\n').length, 5);
|
|
72
|
+
assert.ok(truncated);
|
|
73
|
+
assert.deepEqual(previewText('hola'), { preview: 'hola', truncated: false });
|
|
74
|
+
// Un objeto se serializa con formato.
|
|
75
|
+
assert.match(previewText({ a: 1 }).preview, /"a": 1/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('userText y userImages separan las partes del mensaje multimodal', () => {
|
|
79
|
+
assert.equal(userText('plano'), 'plano');
|
|
80
|
+
assert.equal(
|
|
81
|
+
userText([
|
|
82
|
+
{ type: 'text', text: 'hola' },
|
|
83
|
+
{ type: 'text', text: 'mundo' },
|
|
84
|
+
{ type: 'image_url', image_url: { url: 'data:x' } },
|
|
85
|
+
]),
|
|
86
|
+
'hola\nmundo',
|
|
87
|
+
);
|
|
88
|
+
assert.equal(userText(null), '');
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
userImages([
|
|
91
|
+
{ type: 'image_url', image_url: { url: 'data:x' } },
|
|
92
|
+
{ type: 'text', text: 'y' },
|
|
93
|
+
{ type: 'image_url', image_url: {} },
|
|
94
|
+
]),
|
|
95
|
+
['data:x'],
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(userImages('plano'), []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('advisorMeta cae al default con un tipo desconocido', () => {
|
|
101
|
+
assert.equal(advisorMeta('code'), ADVISOR_KINDS.code);
|
|
102
|
+
assert.equal(advisorMeta('nope').label, 'Asesor');
|
|
103
|
+
assert.ok(ADVISOR_KINDS.summary);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('basename corta el ultimo segmento y los separadores finales', () => {
|
|
107
|
+
assert.equal(basename('a/b/c'), 'c');
|
|
108
|
+
assert.equal(basename('a\\b\\c\\'), 'c');
|
|
109
|
+
// Solo separadores: no hay segmento que devolver.
|
|
110
|
+
assert.equal(basename('/'), '');
|
|
111
|
+
assert.equal(basename(null), '');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('formatDuration da ms cortos y segundos con decimal', () => {
|
|
115
|
+
assert.equal(formatDuration(0), '');
|
|
116
|
+
assert.equal(formatDuration(undefined), '');
|
|
117
|
+
assert.equal(formatDuration(300), '300ms');
|
|
118
|
+
assert.equal(formatDuration(1200), '1.2s');
|
|
119
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// El grafo de imports de la consola (`public/`), verificado sin navegador.
|
|
2
|
+
//
|
|
3
|
+
// La consola no tiene tests de DOM: si un refactor borra un export, renombra una
|
|
4
|
+
// funcion o mueve una llamada a tiempo de evaluacion dentro de un ciclo, la
|
|
5
|
+
// suite no lo ve y el navegador se queda con la pantalla en blanco. Esto cierra
|
|
6
|
+
// ese hueco: valida que cada import resuelve a un export real y que los ciclos
|
|
7
|
+
// (chrome <-> chat, app <-> explorer) siguen seguros.
|
|
8
|
+
//
|
|
9
|
+
// Es analisis estatico sobre el texto: no importa el modulo, asi que no hace
|
|
10
|
+
// falta emular el DOM.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
|
18
|
+
|
|
19
|
+
const lee = (archivo) => readFileSync(path.join(DIR, archivo), 'utf8');
|
|
20
|
+
const modulos = readdirSync(DIR).filter((f) => f.endsWith('.js'));
|
|
21
|
+
|
|
22
|
+
/** Nombres exportados de un modulo: `export function X`, `export { A, B }`, `export const`. */
|
|
23
|
+
function exportsDe(archivo) {
|
|
24
|
+
const src = lee(archivo);
|
|
25
|
+
const nombres = new Set();
|
|
26
|
+
for (const m of src.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) nombres.add(m[1]);
|
|
27
|
+
for (const m of src.matchAll(/export\s+(?:const|let|var|class)\s+([A-Za-z_$][\w$]*)/g)) nombres.add(m[1]);
|
|
28
|
+
// `export { a, b } from './x.js'` tambien expone esos nombres.
|
|
29
|
+
for (const m of src.matchAll(/export\s*\{([^}]*)\}\s*from\s*'[^']+'/g)) {
|
|
30
|
+
for (const bruto of m[1].split(',')) {
|
|
31
|
+
const nombre = bruto.trim().split(/\s+as\s+/).pop().trim();
|
|
32
|
+
if (nombre) nombres.add(nombre);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return nombres;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Imports relativos: `{ nombres, de }` por cada `import { ... } from './x.js'`. */
|
|
39
|
+
function importsDe(archivo) {
|
|
40
|
+
const src = lee(archivo);
|
|
41
|
+
const lista = [];
|
|
42
|
+
for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*'(\.[^']+)'/g)) {
|
|
43
|
+
const nombres = m[1]
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((n) => n.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
const de = path.basename(m[2]);
|
|
48
|
+
lista.push({ nombres, de });
|
|
49
|
+
}
|
|
50
|
+
return lista;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const exportsPorModulo = new Map(modulos.map((m) => [m, exportsDe(m)]));
|
|
54
|
+
const importsPorModulo = new Map(modulos.map((m) => [m, importsDe(m)]));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Statements de nivel superior de un modulo, agrupando por balance de llaves y
|
|
58
|
+
* parentesis. Asi `const x = f();` sale como un statement, y un
|
|
59
|
+
* `addEventListener(...)` con un callback multilinea tambien.
|
|
60
|
+
*/
|
|
61
|
+
function statementsTopLevel(src) {
|
|
62
|
+
const stmts = [];
|
|
63
|
+
let actual = '';
|
|
64
|
+
let inicio = 0;
|
|
65
|
+
let llaves = 0;
|
|
66
|
+
let corchetes = 0;
|
|
67
|
+
let parentesis = 0;
|
|
68
|
+
let enCadena = '';
|
|
69
|
+
let pos = 0;
|
|
70
|
+
for (const linea of src.split(/\r?\n/)) {
|
|
71
|
+
if (!actual) inicio = pos;
|
|
72
|
+
actual += `${actual ? '\n' : ''}${linea}`;
|
|
73
|
+
for (let i = 0; i < linea.length; i += 1) {
|
|
74
|
+
const c = linea[i];
|
|
75
|
+
const previo = linea[i - 1];
|
|
76
|
+
if (enCadena) {
|
|
77
|
+
if (c === enCadena && previo !== '\\') enCadena = '';
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
81
|
+
enCadena = c;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === '{') llaves += 1;
|
|
85
|
+
else if (c === '}') llaves -= 1;
|
|
86
|
+
else if (c === '[') corchetes += 1;
|
|
87
|
+
else if (c === ']') corchetes -= 1;
|
|
88
|
+
else if (c === '(') parentesis += 1;
|
|
89
|
+
else if (c === ')') parentesis -= 1;
|
|
90
|
+
}
|
|
91
|
+
pos += linea.length + 1;
|
|
92
|
+
if (llaves <= 0 && corchetes <= 0 && parentesis <= 0) {
|
|
93
|
+
if (actual.trim()) stmts.push({ texto: actual, inicio });
|
|
94
|
+
actual = '';
|
|
95
|
+
llaves = 0;
|
|
96
|
+
corchetes = 0;
|
|
97
|
+
parentesis = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (actual.trim()) stmts.push({ texto: actual, inicio });
|
|
101
|
+
return stmts;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Quita del statement lo que se ejecuta **despues** del arranque: cuerpos de
|
|
106
|
+
* callbacks (`=> {...}`, `=> expr`, `function () {...}`) y literales de datos
|
|
107
|
+
* (`= [...]`, `= {...}`), que a lo sumo guardan referencias. Lo que queda es lo
|
|
108
|
+
* que se evalua al cargar el modulo.
|
|
109
|
+
*
|
|
110
|
+
* Recorre con estado de cadenas: un `=>` dentro de un template no es un arrow.
|
|
111
|
+
*/
|
|
112
|
+
function sinDiferidos(stmt) {
|
|
113
|
+
const s = stmt.replace(/\r?\n/g, ' ');
|
|
114
|
+
let out = '';
|
|
115
|
+
let i = 0;
|
|
116
|
+
let cadena = '';
|
|
117
|
+
const cortarBloque = (desde) => {
|
|
118
|
+
// Corta hasta el cierre balanceado de `{`/`[` abierto en `desde - 1`.
|
|
119
|
+
let prof = 1;
|
|
120
|
+
let j = desde;
|
|
121
|
+
let cad = '';
|
|
122
|
+
for (; j < s.length; j += 1) {
|
|
123
|
+
const c = s[j];
|
|
124
|
+
const previo = s[j - 1];
|
|
125
|
+
if (cad) {
|
|
126
|
+
if (c === cad && previo !== '\\') cad = '';
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
130
|
+
cad = c;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (c === '{' || c === '[') prof += 1;
|
|
134
|
+
else if (c === '}' || c === ']') {
|
|
135
|
+
prof -= 1;
|
|
136
|
+
if (prof === 0) return j + 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return j;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
while (i < s.length) {
|
|
143
|
+
const c = s[i];
|
|
144
|
+
const previo = s[i - 1];
|
|
145
|
+
if (cadena) {
|
|
146
|
+
out += c;
|
|
147
|
+
if (c === cadena && previo !== '\\') cadena = '';
|
|
148
|
+
i += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
152
|
+
cadena = c;
|
|
153
|
+
out += c;
|
|
154
|
+
i += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const resto = s.slice(i);
|
|
158
|
+
if (c === '=' && s[i + 1] === '>') {
|
|
159
|
+
const despues = s.slice(i + 2).match(/^\s*([[{])/);
|
|
160
|
+
if (despues) {
|
|
161
|
+
const inicio = i + 2 + despues[0].length;
|
|
162
|
+
i = cortarBloque(inicio);
|
|
163
|
+
} else {
|
|
164
|
+
i = s.length; // arrow de expression: su cuerpo corre al invocarlo
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const fn = /^\bfunction\b\s*[\w$]*\s*\([^)]*\)\s*\{/.exec(resto);
|
|
169
|
+
if (fn) {
|
|
170
|
+
out += fn[0].slice(0, -1);
|
|
171
|
+
i = cortarBloque(i + fn[0].length);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (c === '=' && /^\s*[[{]/.test(s.slice(i + 1))) {
|
|
175
|
+
const inicio = i + 1 + s.slice(i + 1).match(/^\s*[[{]/)[0].length;
|
|
176
|
+
out += '=';
|
|
177
|
+
i = cortarBloque(inicio);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
out += c;
|
|
181
|
+
i += 1;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Profundidad de llaves antes del caracter `hasta` (para saber si es nivel 0). */
|
|
187
|
+
function profundidadEn(src, hasta) {
|
|
188
|
+
let prof = 0;
|
|
189
|
+
let cadena = '';
|
|
190
|
+
for (let i = 0; i < hasta; i += 1) {
|
|
191
|
+
const c = src[i];
|
|
192
|
+
const previo = src[i - 1];
|
|
193
|
+
if (cadena) {
|
|
194
|
+
if (c === cadena && previo !== '\\') cadena = '';
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
198
|
+
cadena = c;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (c === '{') prof += 1;
|
|
202
|
+
else if (c === '}') prof -= 1;
|
|
203
|
+
}
|
|
204
|
+
return prof;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test('cada import de la consola resuelve a un export real', () => {
|
|
208
|
+
const rotos = [];
|
|
209
|
+
for (const [modulo, imports] of importsPorModulo) {
|
|
210
|
+
for (const { nombres, de } of imports) {
|
|
211
|
+
if (!exportsPorModulo.has(de)) {
|
|
212
|
+
rotos.push(`${modulo}: importa de "${de}", que no existe`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const expuestos = exportsPorModulo.get(de);
|
|
216
|
+
for (const nombre of nombres) {
|
|
217
|
+
if (!expuestos.has(nombre)) rotos.push(`${modulo}: "${nombre}" no existe en ${de}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
assert.deepEqual(rotos, [], `imports rotos:\n ${rotos.join('\n ')}`);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('los ciclos de la consola son seguros (nada se usa en tiempo de evaluacion)', () => {
|
|
225
|
+
// Construye el grafo dirigido y busca ciclos.
|
|
226
|
+
const grafo = new Map(modulos.map((m) => [m, [...new Set(importsPorModulo.get(m).map((i) => i.de))].filter((d) => exportsPorModulo.has(d))]));
|
|
227
|
+
const ciclos = [];
|
|
228
|
+
const camino = [];
|
|
229
|
+
const enCamino = new Set();
|
|
230
|
+
const visitado = new Set();
|
|
231
|
+
|
|
232
|
+
const dfs = (nodo) => {
|
|
233
|
+
if (enCamino.has(nodo)) {
|
|
234
|
+
const desde = camino.indexOf(nodo);
|
|
235
|
+
ciclos.push([...camino.slice(desde), nodo].join(' -> '));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (visitado.has(nodo)) return;
|
|
239
|
+
visitado.add(nodo);
|
|
240
|
+
enCamino.add(nodo);
|
|
241
|
+
camino.push(nodo);
|
|
242
|
+
for (const vecino of grafo.get(nodo) ?? []) dfs(vecino);
|
|
243
|
+
camino.pop();
|
|
244
|
+
enCamino.delete(nodo);
|
|
245
|
+
};
|
|
246
|
+
for (const nodo of grafo.keys()) dfs(nodo);
|
|
247
|
+
|
|
248
|
+
// Un ciclo es seguro mientras nadie USE un importado durante la evaluacion del
|
|
249
|
+
// modulo. Las funciones y clases estan izadas, asi que referenciarlas en un
|
|
250
|
+
// callback (`addEventListener('click', cerrar)`) es legal: corre despues. Lo
|
|
251
|
+
// que rompe el ciclo es **invocarlas** en nivel superior.
|
|
252
|
+
const enCiclo = new Set(ciclos.flatMap((c) => c.split(' -> ')));
|
|
253
|
+
// `app.js` es el entry point: lo carga el HTML, asi que su codigo de nivel
|
|
254
|
+
// superior corre cuando todo el grafo ya esta evaluado y no hay nada en la
|
|
255
|
+
// zona muerta. Ciclo con el, pero inofensivo.
|
|
256
|
+
const peligrosos = [];
|
|
257
|
+
for (const miembro of enCiclo) {
|
|
258
|
+
if (!miembro.endsWith('.js') || miembro === 'app.js') continue;
|
|
259
|
+
const propios = new Set(importsPorModulo.get(miembro).filter((i) => enCiclo.has(i.de)).flatMap((i) => i.nombres));
|
|
260
|
+
if (!propios.size) continue;
|
|
261
|
+
const src = lee(miembro);
|
|
262
|
+
for (const { texto: stmt, inicio } of statementsTopLevel(src)) {
|
|
263
|
+
// Solo nivel superior: dentro de un `switch` (los `case` de un comando) o
|
|
264
|
+
// de una funcion el codigo corre despues, no al cargar.
|
|
265
|
+
if (profundidadEn(src, inicio) !== 0) continue;
|
|
266
|
+
// Fuera de callbacks y literales: lo que se ejecuta al cargar el modulo.
|
|
267
|
+
const fuera = sinDiferidos(stmt);
|
|
268
|
+
for (const nombre of propios) {
|
|
269
|
+
if (new RegExp(`(^|[^\\w$.])${nombre}\\s*\\(`).test(fuera)) {
|
|
270
|
+
peligrosos.push(`${miembro}: ${stmt.trim().replace(/\s+/g, ' ').slice(0, 70)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
assert.deepEqual(peligrosos, [], `un ciclo invoca un importado en tiempo de evaluacion:\n ${peligrosos.join('\n ')}`);
|
|
276
|
+
// Estos ciclos existen a proposito; si desaparecen, cambio el grafo y
|
|
277
|
+
// conviene revisarlo a mano.
|
|
278
|
+
assert.ok(ciclos.length > 0, 'no se encontraron ciclos: cambio el grafo, revisar a mano');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('los modulos de la consola no exportan nombres repetidos entre si', () => {
|
|
282
|
+
// Un `export function x` duplicado en dos modulos suele ser codigo movido dos
|
|
283
|
+
// veces al partir un archivo grande.
|
|
284
|
+
const visto = new Map();
|
|
285
|
+
const duplicados = [];
|
|
286
|
+
for (const [modulo, nombres] of exportsPorModulo) {
|
|
287
|
+
for (const nombre of nombres) {
|
|
288
|
+
if (visto.has(nombre)) duplicados.push(`${nombre}: ${visto.get(nombre)} y ${modulo}`);
|
|
289
|
+
else visto.set(nombre, modulo);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// `index`-style re-exports (chat.js re-exporta chatFormat.js) son esperados.
|
|
293
|
+
const reexports = new Set();
|
|
294
|
+
for (const [modulo] of importsPorModulo) {
|
|
295
|
+
const src = lee(modulo);
|
|
296
|
+
for (const m of src.matchAll(/export\s*\{([^}]*)\}\s*from/g)) {
|
|
297
|
+
for (const bruto of m[1].split(',')) reexports.add(bruto.trim().split(/\s+as\s+/).pop().trim());
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
assert.deepEqual(duplicados.filter((d) => !reexports.has(d.split(':')[0])), []);
|
|
301
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Smoke test de la consola con un navegador de verdad.
|
|
2
|
+
//
|
|
3
|
+
// La consola es la parte del harness que ningun test cubria: si un cambio rompe
|
|
4
|
+
// un import, deja un `undefined` al cargar o dispara un error en el arranque,
|
|
5
|
+
// la pantalla queda en blanco y la suite seguia en verde. Esto la abre en el
|
|
6
|
+
// Chrome/Edge del sistema (el mismo navegador headless de las tools `screenshot`
|
|
7
|
+
// y `console_errors`) y exige que **no haya errores de consola**.
|
|
8
|
+
//
|
|
9
|
+
// Se saltea si no hay navegador: es una verificacion de UI, no del engine.
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { findBrowserPath, readConsole } from '../../core/src/browser/index.js';
|
|
13
|
+
import { startServer } from './helpers.js';
|
|
14
|
+
|
|
15
|
+
const HAY_NAVEGADOR = Boolean(findBrowserPath());
|
|
16
|
+
|
|
17
|
+
test('la consola carga sin errores de consola', { skip: HAY_NAVEGADOR ? false : 'sin navegador headless' }, async () => {
|
|
18
|
+
const { instance, base } = await startServer();
|
|
19
|
+
try {
|
|
20
|
+
const { errors } = await readConsole(base, { waitMs: 2500, timeoutMs: 30000, allowPrivate: true });
|
|
21
|
+
// Solo importan los errores de la consola. El favicon y el ruido de red (la
|
|
22
|
+
// consola pide fuentes a Google) no son fallos del codigo: sin conexion el
|
|
23
|
+
// test fallaria por algo que no controlamos.
|
|
24
|
+
const ruido = /favicon|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION|ERR_NETWORK|Failed to load resource/i;
|
|
25
|
+
const reales = errors.filter((e) => !ruido.test(String(e?.text ?? e?.message ?? e)));
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
reales.map((e) => e?.text ?? e?.message ?? String(e)),
|
|
28
|
+
[],
|
|
29
|
+
'la consola tiro errores al cargar',
|
|
30
|
+
);
|
|
31
|
+
} finally {
|
|
32
|
+
await instance.close();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
6
|
+
import { setHome } from '../../core/src/index.js';
|
|
7
|
+
|
|
8
|
+
const fakeProvider = {
|
|
9
|
+
id: 'fake',
|
|
10
|
+
async streamChat(_config, { onEvent }) {
|
|
11
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
12
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function startServer(overrides = {}) {
|
|
17
|
+
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-srv-')));
|
|
18
|
+
setHome(cwd);
|
|
19
|
+
const instance = await createTamnoraServer({
|
|
20
|
+
cwd,
|
|
21
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
22
|
+
loadMcp: false,
|
|
23
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
const info = await instance.listen({ port: 0 });
|
|
27
|
+
return { instance, base: info.url };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HAS_GIT = (() => {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync('git', ['--version'], { stdio: 'ignore' });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
function gitInit(root) {
|
|
40
|
+
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
41
|
+
execFileSync('git', ['config', 'user.email', 'test@tamnora.dev'], { cwd: root });
|
|
42
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readSse(url, options, until, timeoutMs = 5000) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
48
|
+
const frames = [];
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(url, { ...options, signal: controller.signal });
|
|
51
|
+
const reader = res.body.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let buffer = '';
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { value, done } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buffer += decoder.decode(value, { stream: true });
|
|
58
|
+
let index;
|
|
59
|
+
while ((index = buffer.indexOf('\n\n')) !== -1) {
|
|
60
|
+
const frame = buffer.slice(0, index);
|
|
61
|
+
buffer = buffer.slice(index + 2);
|
|
62
|
+
let event = null;
|
|
63
|
+
let data = '';
|
|
64
|
+
for (const line of frame.split('\n')) {
|
|
65
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
66
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
67
|
+
}
|
|
68
|
+
if (!event) continue;
|
|
69
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
70
|
+
frames.push({ event, data: parsed });
|
|
71
|
+
if (until(parsed, event)) return frames;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.name !== 'AbortError') throw error;
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
return frames;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Espera una condicion (no un tiempo fijo): los procesos y los servicios tardan
|
|
84
|
+
* lo que tardan, y una espera fija solo hace flaky al test cuando la maquina
|
|
85
|
+
* esta cargada.
|
|
86
|
+
*/
|
|
87
|
+
async function waitFor(cond, timeoutMs = 5000, stepMs = 50) {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (await cond()) return true;
|
|
91
|
+
if (Date.now() - start >= timeoutMs) return false;
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function waitIdle(base, id, attempts = 60) {
|
|
97
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
98
|
+
const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
99
|
+
if (!detail.running) return true;
|
|
100
|
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readEvents(res, onFrame) {
|
|
107
|
+
const reader = res.body.getReader();
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let buffer = '';
|
|
110
|
+
let done = false;
|
|
111
|
+
while (!done) {
|
|
112
|
+
const { value } = await reader.read();
|
|
113
|
+
if (!value) break;
|
|
114
|
+
buffer += decoder.decode(value, { stream: true });
|
|
115
|
+
if (onFrame) await onFrame(buffer);
|
|
116
|
+
if (buffer.includes('event: done')) done = true;
|
|
117
|
+
}
|
|
118
|
+
return buffer;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { fakeProvider, startServer, HAS_GIT, gitInit, readSse, waitFor, waitIdle, readEvents };
|