@danieltmn/tamnora 0.8.0 → 0.9.1
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 +89 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4665 -3975
- package/harness/packages/server/public/index.html +62 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
11
|
+
import { setHome, startRelay, randomToken } from '../../core/src/index.js';
|
|
12
|
+
|
|
13
|
+
const HAS_PHP = (() => {
|
|
14
|
+
try {
|
|
15
|
+
execFileSync('php', ['-v'], { stdio: 'ignore' });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
const fakeProvider = {
|
|
23
|
+
id: 'fake',
|
|
24
|
+
async streamChat(_config, { onEvent }) {
|
|
25
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
26
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** SSE crudo por GET crudo (fetch con streaming del lado Node). */
|
|
31
|
+
async function sseGet(url, { expectFrames = 1, timeoutMs = 30000 } = {}) {
|
|
32
|
+
const frames = [];
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
37
|
+
const reader = res.body.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
let buf = '';
|
|
40
|
+
while (frames.length < expectFrames) {
|
|
41
|
+
const { value, done } = await reader.read();
|
|
42
|
+
if (done) break;
|
|
43
|
+
buf += decoder.decode(value, { stream: true });
|
|
44
|
+
let index;
|
|
45
|
+
while ((index = buf.indexOf('\n\n')) !== -1) {
|
|
46
|
+
const frame = buf.slice(0, index);
|
|
47
|
+
buf = buf.slice(index + 2);
|
|
48
|
+
let event = null;
|
|
49
|
+
let data = '';
|
|
50
|
+
let id = null;
|
|
51
|
+
for (const line of frame.split('\n')) {
|
|
52
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
53
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
54
|
+
else if (line.startsWith('id:')) id = line.slice(3).trim();
|
|
55
|
+
}
|
|
56
|
+
if (!event && !data) continue;
|
|
57
|
+
let parsed = {};
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(data);
|
|
60
|
+
} catch {
|
|
61
|
+
parsed = { raw: data };
|
|
62
|
+
}
|
|
63
|
+
frames.push({ event, data: parsed, id });
|
|
64
|
+
if (frames.length >= expectFrames) {
|
|
65
|
+
controller.abort();
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.name !== 'AbortError' && frames.length < expectFrames) throw error;
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
return frames;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function phpServer(docroot) {
|
|
79
|
+
const port = 44000 + Math.floor(Math.random() * 4000);
|
|
80
|
+
const args = ['-S', `127.0.0.1:${port}`, '-t', docroot];
|
|
81
|
+
const child = spawn('php', args, {
|
|
82
|
+
env: { ...process.env },
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
});
|
|
86
|
+
let stderr = '';
|
|
87
|
+
child.stderr.on('data', (d) => (stderr += String(d)));
|
|
88
|
+
let exited = null;
|
|
89
|
+
child.on('exit', (code) => (exited = code));
|
|
90
|
+
const base = `http://127.0.0.1:${port}/index.php`;
|
|
91
|
+
// esperar a que escuche
|
|
92
|
+
let lastError = '';
|
|
93
|
+
for (let i = 0; i < 60; i++) {
|
|
94
|
+
try {
|
|
95
|
+
await fetch(`${base}?a=status`, { signal: AbortSignal.timeout(1000) });
|
|
96
|
+
return { child, base };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
lastError = error?.message ?? String(error);
|
|
99
|
+
if (exited !== null) break;
|
|
100
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(
|
|
104
|
+
`php server no arranco: ${JSON.stringify({ docroot, exited, lastError, stderr: stderr.slice(0, 400) })}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* El PHP built-in en Windows es monohilo (PHP_CLI_SERVER_WORKERS no existe
|
|
110
|
+
* alla); send/poll largos se bloquearian mutuamente. Para el test corremos
|
|
111
|
+
* DOS servers sobre la misma SQLite (WAL es multi-proceso): el agente usa
|
|
112
|
+
* uno, el browser el otro. En cPanel con FPM esto no hace falta.
|
|
113
|
+
*/
|
|
114
|
+
async function relayStack(docroot) {
|
|
115
|
+
const agent = await phpServer(docroot);
|
|
116
|
+
const browser = await phpServer(docroot);
|
|
117
|
+
return { children: [agent.child, browser.child], relayBase: agent.base, browserBase: browser.base };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test('el relay PHP mailbox carga y hace GC', async (t) => {
|
|
121
|
+
if (!HAS_PHP) return t.skip('sin PHP disponible');
|
|
122
|
+
const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
|
|
123
|
+
const { child, base } = await phpServer(docroot);
|
|
124
|
+
try {
|
|
125
|
+
const status = await (await fetch(`${base}?a=status`)).json();
|
|
126
|
+
assert.equal(status.ok, true);
|
|
127
|
+
|
|
128
|
+
const token = randomToken(24);
|
|
129
|
+
const reg = await (
|
|
130
|
+
await fetch(`${base}?a=register`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({ token }),
|
|
134
|
+
})
|
|
135
|
+
).json();
|
|
136
|
+
assert.equal(reg.ok, true);
|
|
137
|
+
assert.equal(reg.cursor, 0);
|
|
138
|
+
|
|
139
|
+
const bad = await fetch(`${base}?a=send&t=${token}`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
body: JSON.stringify({ path: 'nope' }),
|
|
142
|
+
});
|
|
143
|
+
assert.equal(bad.status, 400);
|
|
144
|
+
} finally {
|
|
145
|
+
child.kill('SIGTERM');
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('end-to-end: request no-stream y stream de chat via relay', async (t) => {
|
|
150
|
+
if (!HAS_PHP) return t.skip('sin PHP disponible');
|
|
151
|
+
|
|
152
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-relay-'));
|
|
153
|
+
setHome(cwd);
|
|
154
|
+
const instance = await createTamnoraServer({
|
|
155
|
+
cwd,
|
|
156
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
157
|
+
loadMcp: false,
|
|
158
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
159
|
+
});
|
|
160
|
+
const local = await instance.listen({ port: 0 });
|
|
161
|
+
|
|
162
|
+
const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
|
|
163
|
+
const { children, relayBase, browserBase } = await relayStack(docroot);
|
|
164
|
+
const token = randomToken(24);
|
|
165
|
+
const logs = [];
|
|
166
|
+
const pump = startRelay(local.port, { relayUrl: relayBase, token, log: (line) => logs.push(line) });
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// 1) request sincrono: /api/health pasa entero
|
|
170
|
+
const health = await (
|
|
171
|
+
await fetch(`${browserBase}?a=send&t=${token}`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'Content-Type': 'application/json' },
|
|
174
|
+
body: JSON.stringify({ method: 'GET', path: '/api/health' }),
|
|
175
|
+
})
|
|
176
|
+
).json();
|
|
177
|
+
assert.equal(health.status, 200);
|
|
178
|
+
assert.equal(JSON.parse(Buffer.from(health.body, 'base64').toString('utf8')).ok, true);
|
|
179
|
+
|
|
180
|
+
// 2) crear sesion y correr un turno SSE via mailbox
|
|
181
|
+
const session = await (
|
|
182
|
+
await fetch(`${browserBase}?a=send&t=${token}`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ method: 'POST', path: '/api/sessions', body: JSON.stringify({ title: 'relay' }) }),
|
|
186
|
+
})
|
|
187
|
+
).json();
|
|
188
|
+
assert.equal(session.status, 201, `sesion: ${session.body && Buffer.from(session.body, 'base64').toString('utf8')}`);
|
|
189
|
+
const sessionId = JSON.parse(Buffer.from(session.body, 'base64').toString('utf8')).session.id;
|
|
190
|
+
|
|
191
|
+
const opened = await (
|
|
192
|
+
await fetch(`${browserBase}?a=send&t=${token}`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
method: 'POST',
|
|
197
|
+
path: '/api/chat',
|
|
198
|
+
body: JSON.stringify({ input: 'hola', sessionId, model: 'm' }),
|
|
199
|
+
stream: true,
|
|
200
|
+
}),
|
|
201
|
+
})
|
|
202
|
+
).json();
|
|
203
|
+
assert.ok(opened.rid, 'a=send stream devuelve rid');
|
|
204
|
+
|
|
205
|
+
const frames = await sseGet(`${browserBase}?a=stream&t=${token}&rid=${opened.rid}&from=0`, { expectFrames: 20 });
|
|
206
|
+
const events = frames.filter((f) => f.event === 'chunk').map((f) => f.data.payload?.ev);
|
|
207
|
+
assert.ok(events.includes('start'), `start en ${JSON.stringify(events)}`);
|
|
208
|
+
assert.ok(events.includes('text'), `text en ${JSON.stringify(events)}`);
|
|
209
|
+
const doneRow = frames.some((f) => f.event === 'meta' && f.data.payload?.done === true);
|
|
210
|
+
assert.ok(doneRow, 'la fila final done cierra el stream');
|
|
211
|
+
} catch (error) {
|
|
212
|
+
error.logs = logs.slice();
|
|
213
|
+
throw error;
|
|
214
|
+
} finally {
|
|
215
|
+
pump.stop();
|
|
216
|
+
for (const child of children) child.kill('SIGTERM');
|
|
217
|
+
await instance.close();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { streamSse, followRun, backoffDelay, linkSignals, delay } from '../public/runStream.js';
|
|
4
|
+
|
|
5
|
+
/** Arma una `fetch` falsa que devuelve los chunks indicados y despues se cuelga. */
|
|
6
|
+
function stubFetch(chunks, { status = 200, hang = true } = {}) {
|
|
7
|
+
const queue = [...chunks];
|
|
8
|
+
const enqueue = (controller) => {
|
|
9
|
+
// Sin chunks pendientes el stream queda abierto y mudo: eso es justo lo que
|
|
10
|
+
// el watchdog tiene que detectar, asi que no cerramos nada. Con `hang:false`
|
|
11
|
+
// se cierra. `queueMicrotask` y no `setTimeout`: no deja timers colgados
|
|
12
|
+
// entre tests.
|
|
13
|
+
if (!queue.length) {
|
|
14
|
+
if (!hang) controller.close();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
controller.enqueue(new TextEncoder().encode(queue.shift()));
|
|
18
|
+
queueMicrotask(() => enqueue(controller));
|
|
19
|
+
};
|
|
20
|
+
return async (_url, options = {}) => {
|
|
21
|
+
if (status !== 200) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
status,
|
|
25
|
+
statusText: 'Error',
|
|
26
|
+
json: async () => ({ error: 'no autorizado' }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Un fetch real aborta el body cuando se dispara la senal: hay que
|
|
30
|
+
// reproducirlo o el watchdog no puede cortar el `reader.read()`.
|
|
31
|
+
const body = new ReadableStream({
|
|
32
|
+
start(controller) {
|
|
33
|
+
const stop = () => controller.error(new DOMException('Aborted', 'AbortError'));
|
|
34
|
+
if (options.signal?.aborted) return stop();
|
|
35
|
+
options.signal?.addEventListener('abort', stop, { once: true });
|
|
36
|
+
enqueue(controller);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return { ok: true, status: 200, body };
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('entrega los frames parseados y recuerda el ultimo id', async () => {
|
|
44
|
+
const frames = [];
|
|
45
|
+
await streamSse('/x', { fetchImpl: stubFetch(['event: start\ndata: {"a":1}\nid: 7\n\n: ping\n\nevent: done\ndata: {}\nid: 9\n\n'], { hang: false }) }, (event, data, id) => {
|
|
46
|
+
frames.push([event, data, id]);
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(frames, [
|
|
49
|
+
['start', { a: 1 }, '7'],
|
|
50
|
+
['done', {}, '9'],
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('usa el fetch global cuando no se pasa fetchImpl', async () => {
|
|
55
|
+
// La consola nunca pasa fetchImpl. Si el default se rompe, todos los streams
|
|
56
|
+
// fallan con TypeError y followRun los reintenta para siempre.
|
|
57
|
+
const original = globalThis.fetch;
|
|
58
|
+
let called = false;
|
|
59
|
+
globalThis.fetch = async (url) => {
|
|
60
|
+
called = true;
|
|
61
|
+
assert.equal(url, '/api/events');
|
|
62
|
+
return { ok: true, status: 200, body: new ReadableStream({ start: (c) => c.close() }) };
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
const frames = [];
|
|
66
|
+
await streamSse('/api/events', {}, (event, data) => frames.push([event, data]));
|
|
67
|
+
assert.equal(called, true);
|
|
68
|
+
assert.deepEqual(frames, []);
|
|
69
|
+
} finally {
|
|
70
|
+
globalThis.fetch = original;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('tolera frames partidas entre varios chunks', async () => {
|
|
75
|
+
const frames = [];
|
|
76
|
+
await streamSse('/x', { fetchImpl: stubFetch(['event: te', 'xt\ndata: {"b', '":2}\nid: 3\n\n'], { hang: false }) }, (event, data, id) => {
|
|
77
|
+
frames.push([event, data, id]);
|
|
78
|
+
});
|
|
79
|
+
assert.deepEqual(frames, [['text', { b: 2 }, '3']]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('los comentarios de heartbeat no producen eventos', async () => {
|
|
83
|
+
const frames = [];
|
|
84
|
+
await streamSse('/x', { fetchImpl: stubFetch([': ping\n\n: ping\n\nevent: done\ndata: {}\n\n'], { hang: false }) }, (event) => {
|
|
85
|
+
frames.push(event);
|
|
86
|
+
});
|
|
87
|
+
assert.deepEqual(frames, ['done']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('corta cuando el stream queda mudo y reporta el cursor', async () => {
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
streamSse('/x', { silenceMs: 40, fetchImpl: stubFetch(['event: start\ndata: {}\nid: 12\n\n']) }, () => {}),
|
|
93
|
+
(error) => {
|
|
94
|
+
assert.equal(error.name, 'SilentStream');
|
|
95
|
+
assert.equal(error.resumable, true);
|
|
96
|
+
assert.equal(error.from, '12');
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('propaga el status HTTP para distinguir un 409', async () => {
|
|
103
|
+
await assert.rejects(
|
|
104
|
+
streamSse('/x', { fetchImpl: stubFetch([], { status: 409 }) }, () => {}),
|
|
105
|
+
(error) => {
|
|
106
|
+
assert.equal(error.status, 409);
|
|
107
|
+
assert.equal(error.message, 'no autorizado');
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('el AbortError del caller no se reporta como stream mudo', async () => {
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
controller.abort();
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
streamSse('/x', { signal: controller.signal, fetchImpl: stubFetch([]) }, () => {}),
|
|
118
|
+
(error) => error.name === 'AbortError'
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('followRun reintenta con backoff creciente hasta terminar', async () => {
|
|
123
|
+
const slept = [];
|
|
124
|
+
let calls = 0;
|
|
125
|
+
const result = await followRun({
|
|
126
|
+
open: async (cursor) => {
|
|
127
|
+
calls += 1;
|
|
128
|
+
if (calls < 4) {
|
|
129
|
+
const error = new Error('se corto');
|
|
130
|
+
error.name = 'SilentStream';
|
|
131
|
+
error.from = String(cursor + 10);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
return { finished: true, cursor: 99 };
|
|
135
|
+
},
|
|
136
|
+
sleep: async (ms) => {
|
|
137
|
+
slept.push(ms);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
assert.equal(calls, 4);
|
|
141
|
+
assert.deepEqual(slept, [1000, 2000, 4000]);
|
|
142
|
+
assert.equal(result.finished, true);
|
|
143
|
+
assert.equal(result.cursor, 99);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('followRun no reintenta un AbortError', async () => {
|
|
147
|
+
let calls = 0;
|
|
148
|
+
await assert.rejects(
|
|
149
|
+
followRun({
|
|
150
|
+
open: async () => {
|
|
151
|
+
calls += 1;
|
|
152
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
153
|
+
},
|
|
154
|
+
sleep: async () => {},
|
|
155
|
+
}),
|
|
156
|
+
(error) => error.name === 'AbortError'
|
|
157
|
+
);
|
|
158
|
+
assert.equal(calls, 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('followRun respeta el limite de intentos', async () => {
|
|
162
|
+
let calls = 0;
|
|
163
|
+
await assert.rejects(
|
|
164
|
+
followRun({
|
|
165
|
+
attempts: 2,
|
|
166
|
+
open: async () => {
|
|
167
|
+
calls += 1;
|
|
168
|
+
throw new Error('siempre falla');
|
|
169
|
+
},
|
|
170
|
+
sleep: async () => {},
|
|
171
|
+
}),
|
|
172
|
+
/siempre falla/
|
|
173
|
+
);
|
|
174
|
+
assert.equal(calls, 3);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('followRun avisa el cursor que va a reanudar', async () => {
|
|
178
|
+
const retries = [];
|
|
179
|
+
await followRun({
|
|
180
|
+
open: async (cursor) => {
|
|
181
|
+
if (cursor === 0) {
|
|
182
|
+
const error = new Error('murio');
|
|
183
|
+
error.name = 'SilentStream';
|
|
184
|
+
error.from = '55';
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
return { finished: true };
|
|
188
|
+
},
|
|
189
|
+
onRetry: (info) => retries.push([info.cursor, info.attempt]),
|
|
190
|
+
sleep: async () => {},
|
|
191
|
+
});
|
|
192
|
+
assert.deepEqual(retries, [[55, 1]]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('un error del handler no se reintenta en bucle', async () => {
|
|
196
|
+
// Si se confundiera con una caida del transporte, el sintoma seria un
|
|
197
|
+
// "reconectando..." eterno que no dice nada del bug real.
|
|
198
|
+
let calls = 0;
|
|
199
|
+
const boom = await followRun({
|
|
200
|
+
attempts: Infinity,
|
|
201
|
+
open: async () => {
|
|
202
|
+
calls += 1;
|
|
203
|
+
const error = new Error('handler roto');
|
|
204
|
+
error.eventHandler = true;
|
|
205
|
+
throw error;
|
|
206
|
+
},
|
|
207
|
+
sleep: async () => {},
|
|
208
|
+
}).catch((error) => error);
|
|
209
|
+
assert.equal(calls, 1, 'no debe reintentar');
|
|
210
|
+
assert.ok(boom.eventHandler, 'debe marcar el error como del handler');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('un error del handler se propaga marcado', async () => {
|
|
214
|
+
await assert.rejects(
|
|
215
|
+
streamSse('/x', { fetchImpl: stubFetch(['event: text\ndata: {}\n\n'], { hang: false }) }, () => {
|
|
216
|
+
throw new Error('se rompio el render');
|
|
217
|
+
}),
|
|
218
|
+
(error) => {
|
|
219
|
+
assert.equal(error.message, 'se rompio el render');
|
|
220
|
+
assert.equal(error.eventHandler, true);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('backoffDelay acota en el ultimo paso', () => {
|
|
227
|
+
assert.equal(backoffDelay(0), 1000);
|
|
228
|
+
assert.equal(backoffDelay(3), 8000);
|
|
229
|
+
assert.equal(backoffDelay(99), 10000);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('linkSignals aborta si cualquiera de las senales dispara', () => {
|
|
233
|
+
const a = new AbortController();
|
|
234
|
+
const b = new AbortController();
|
|
235
|
+
const linked = linkSignals([a.signal, b.signal]);
|
|
236
|
+
assert.equal(linked.signal.aborted, false);
|
|
237
|
+
b.abort();
|
|
238
|
+
assert.equal(linked.signal.aborted, true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('delay se corta cuando abortan', async () => {
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
setTimeout(() => controller.abort(), 5);
|
|
244
|
+
await assert.rejects(delay(5000, controller.signal), (error) => error.name === 'AbortError');
|
|
245
|
+
});
|