tracereports 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md CHANGED
@@ -1,13 +1,12 @@
1
- # tracereports (cliente JavaScript)
2
-
3
- 🌐 [English](README.md) · **Español**
4
-
5
- Cliente del servidor [TraceReports](../../README.es.md): Playwright Test (reporter + fixtures),
6
- Selenium WebDriver y cualquier runner. Node 18+, sin dependencias.
7
-
8
- ```bash
9
- npm install --save-dev ../ruta/a/tracereports/client/js
10
- npm test # pruebas del cliente (node:test)
11
- ```
12
-
13
- Documentación completa: [docs/es/javascript.md](../../docs/es/javascript.md).
1
+ # tracereports (cliente JavaScript)
2
+
3
+ 🌐 [English](README.md) · **Español**
4
+
5
+ Cliente del servidor [TraceReports](https://github.com/josemiguellopez/tracereports): Playwright Test (reporter + fixtures),
6
+ Selenium WebDriver y cualquier runner. Node 18+, sin dependencias.
7
+
8
+ ```bash
9
+ npm install --save-dev tracereports
10
+ ```
11
+
12
+ Documentación completa: [docs/es/javascript.md](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/javascript.md).
package/README.md CHANGED
@@ -1,13 +1,12 @@
1
- # tracereports (JavaScript client)
2
-
3
- 🌐 **English** · [Español](README.es.md)
4
-
5
- Client for the [TraceReports](../../README.md) server: Playwright Test (reporter + fixtures),
6
- Selenium WebDriver and any runner. Node 18+, no dependencies.
7
-
8
- ```bash
9
- npm install --save-dev ../path/to/tracereports/client/js
10
- npm test # client tests (node:test)
11
- ```
12
-
13
- Full documentation: [docs/en/javascript.md](../../docs/en/javascript.md).
1
+ # tracereports (JavaScript client)
2
+
3
+ 🌐 **English** · [Español](README.es.md)
4
+
5
+ Client for the [TraceReports](https://github.com/josemiguellopez/tracereports) server: Playwright Test (reporter + fixtures),
6
+ Selenium WebDriver and any runner. Node 18+, no dependencies.
7
+
8
+ ```bash
9
+ npm install --save-dev tracereports
10
+ ```
11
+
12
+ Full documentation: [docs/en/javascript.md](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/javascript.md).
package/package.json CHANGED
@@ -1,57 +1,57 @@
1
- {
2
- "name": "tracereports",
3
- "version": "0.1.0",
4
- "description": "Cliente de TraceReports para JavaScript/TypeScript: Playwright Test (reporter + fixtures), Selenium WebDriver y cualquier runner.",
5
- "keywords": [
6
- "testing",
7
- "test-reporting",
8
- "playwright",
9
- "selenium",
10
- "e2e",
11
- "qa",
12
- "reporter",
13
- "ai"
14
- ],
15
- "homepage": "https://github.com/josemiguellopez/tracereports#readme",
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/josemiguellopez/tracereports.git",
19
- "directory": "client/js"
20
- },
21
- "bugs": {
22
- "url": "https://github.com/josemiguellopez/tracereports/issues"
23
- },
24
- "author": "Jose Lopez",
25
- "type": "module",
26
- "main": "./src/index.js",
27
- "types": "./src/index.d.ts",
28
- "exports": {
29
- ".": {
30
- "types": "./src/index.d.ts",
31
- "default": "./src/index.js"
32
- },
33
- "./reporter": "./src/reporter.js",
34
- "./playwright": "./src/playwright.js",
35
- "./selenium": "./src/selenium.js"
36
- },
37
- "files": [
38
- "src",
39
- "README.md",
40
- "README.es.md"
41
- ],
42
- "engines": {
43
- "node": ">=18"
44
- },
45
- "scripts": {
46
- "test": "node --test \"test/*.test.js\""
47
- },
48
- "peerDependencies": {
49
- "@playwright/test": ">=1.40"
50
- },
51
- "peerDependenciesMeta": {
52
- "@playwright/test": {
53
- "optional": true
54
- }
55
- },
56
- "license": "Apache-2.0"
57
- }
1
+ {
2
+ "name": "tracereports",
3
+ "version": "0.1.2",
4
+ "description": "TraceReports client for JavaScript/TypeScript: Playwright Test reporter and fixtures, Selenium WebDriver helpers and a client for any runner.",
5
+ "keywords": [
6
+ "testing",
7
+ "test-reporting",
8
+ "playwright",
9
+ "selenium",
10
+ "e2e",
11
+ "qa",
12
+ "reporter",
13
+ "ai"
14
+ ],
15
+ "homepage": "https://github.com/josemiguellopez/tracereports#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/josemiguellopez/tracereports.git",
19
+ "directory": "client/js"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/josemiguellopez/tracereports/issues"
23
+ },
24
+ "author": "Jose Lopez",
25
+ "type": "module",
26
+ "main": "./src/index.js",
27
+ "types": "./src/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./src/index.d.ts",
31
+ "default": "./src/index.js"
32
+ },
33
+ "./reporter": "./src/reporter.js",
34
+ "./playwright": "./src/playwright.js",
35
+ "./selenium": "./src/selenium.js"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "README.es.md"
41
+ ],
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "scripts": {
46
+ "test": "node --test \"test/*.test.js\""
47
+ },
48
+ "peerDependencies": {
49
+ "@playwright/test": ">=1.40"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@playwright/test": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "license": "Apache-2.0"
57
+ }
package/src/dom.js CHANGED
@@ -1,29 +1,29 @@
1
- // Snapshot de la página al fallar un test: entrada del "AI Locator Recommender". No es el HTML
2
- // completo, sino los elementos con los que se suele interactuar, con atributos y posición en
3
- // pantalla. El mismo script que usan los clientes Python y Go.
4
-
5
- /** Función que corre en el navegador (máximo 1500 elementos). */
6
- export function domSnapshotInPage() {
7
- const sel = "a,button,input,select,textarea,label,summary,[role],[data-testid],[data-test-id],[data-test],[id],[name],[aria-label],[placeholder],h1,h2,h3,h4,h5,h6";
8
- const out = [];
9
- for (const el of document.querySelectorAll(sel)) {
10
- if (out.length >= 1500) break;
11
- const r = el.getBoundingClientRect();
12
- const st = getComputedStyle(el);
13
- const visible = r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none" && st.opacity !== "0";
14
- const testAttr = ["data-testid", "data-test-id", "data-test"].find((a) => el.hasAttribute(a)) || "";
15
- const label = el.getAttribute("aria-label") || (el.labels && el.labels[0] ? el.labels[0].innerText : "") || "";
16
- const text = el.tagName === "INPUT" || el.tagName === "TEXTAREA" ? "" : (el.innerText || "").trim().replace(/\s+/g, " ");
17
- out.push({
18
- tag: el.tagName.toLowerCase(), id: el.id || "", name: el.getAttribute("name") || "",
19
- role: el.getAttribute("role") || "", testid: testAttr ? el.getAttribute(testAttr) : "", testid_attr: testAttr,
20
- label: label.trim().slice(0, 120), placeholder: (el.getAttribute("placeholder") || "").slice(0, 120),
21
- type: el.getAttribute("type") || "", text: text.slice(0, 120),
22
- x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), visible,
23
- });
24
- }
25
- return { url: location.href, title: document.title, viewport: { w: innerWidth, h: innerHeight }, elements: out };
26
- }
27
-
28
- /** Script como texto, para Selenium (`driver.executeScript`) y otros drivers. */
29
- export const DOM_SCRIPT = `return (${domSnapshotInPage.toString()})();`;
1
+ // Snapshot de la página al fallar un test: entrada del "AI Locator Recommender". No es el HTML
2
+ // completo, sino los elementos con los que se suele interactuar, con atributos y posición en
3
+ // pantalla. El mismo script que usan los clientes Python y Go.
4
+
5
+ /** Función que corre en el navegador (máximo 1500 elementos). */
6
+ export function domSnapshotInPage() {
7
+ const sel = "a,button,input,select,textarea,label,summary,[role],[data-testid],[data-test-id],[data-test],[id],[name],[aria-label],[placeholder],h1,h2,h3,h4,h5,h6";
8
+ const out = [];
9
+ for (const el of document.querySelectorAll(sel)) {
10
+ if (out.length >= 1500) break;
11
+ const r = el.getBoundingClientRect();
12
+ const st = getComputedStyle(el);
13
+ const visible = r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none" && st.opacity !== "0";
14
+ const testAttr = ["data-testid", "data-test-id", "data-test"].find((a) => el.hasAttribute(a)) || "";
15
+ const label = el.getAttribute("aria-label") || (el.labels && el.labels[0] ? el.labels[0].innerText : "") || "";
16
+ const text = el.tagName === "INPUT" || el.tagName === "TEXTAREA" ? "" : (el.innerText || "").trim().replace(/\s+/g, " ");
17
+ out.push({
18
+ tag: el.tagName.toLowerCase(), id: el.id || "", name: el.getAttribute("name") || "",
19
+ role: el.getAttribute("role") || "", testid: testAttr ? el.getAttribute(testAttr) : "", testid_attr: testAttr,
20
+ label: label.trim().slice(0, 120), placeholder: (el.getAttribute("placeholder") || "").slice(0, 120),
21
+ type: el.getAttribute("type") || "", text: text.slice(0, 120),
22
+ x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), visible,
23
+ });
24
+ }
25
+ return { url: location.href, title: document.title, viewport: { w: innerWidth, h: innerHeight }, elements: out };
26
+ }
27
+
28
+ /** Script como texto, para Selenium (`driver.executeScript`) y otros drivers. */
29
+ export const DOM_SCRIPT = `return (${domSnapshotInPage.toString()})();`;
package/src/selenium.js CHANGED
@@ -1,49 +1,49 @@
1
- // Selenium WebDriver (selenium-webdriver) con TraceReports. Sirve con cualquier runner
2
- // (node:test, Mocha, Jest…): no importa selenium-webdriver, usa el `driver` que le pases.
3
- //
4
- // import { TraceReports } from "tracereports";
5
- // import { withDriver, screenshot } from "tracereports/selenium";
6
- //
7
- // const cr = new TraceReports();
8
- // await cr.startRun("Regresión web", { environment: "chrome" });
9
- // await cr.test("Login correcto", withDriver(driver, { key: "login.test.js > ok" }), async (t) => {
10
- // await driver.get(URL);
11
- // t.screenshot(await screenshot(driver), "Formulario de login");
12
- // });
13
- // await cr.finishRun();
14
- //
15
- // Si el test falla, `withDriver` adjunta la captura del momento y el snapshot del DOM (con él el
16
- // reporte recomienda selectores cuando se rompe un locator). Selenium no expone la red del
17
- // navegador: para la pestaña "Red" usa Playwright o envía las llamadas con `t.network([...])`.
18
-
19
- import { DOM_SCRIPT } from "./dom.js";
20
-
21
- /** Captura de pantalla como base64 (lo que acepta `t.screenshot`), o null. */
22
- export async function screenshot(driver) {
23
- try {
24
- return await driver.takeScreenshot();
25
- } catch {
26
- return null;
27
- }
28
- }
29
-
30
- /** Snapshot de los elementos de la página, o null si el navegador ya no responde. */
31
- export async function captureDom(driver) {
32
- try {
33
- return await driver.executeScript(DOM_SCRIPT);
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
- /** Opciones para `cr.test(...)` que, al fallar, adjuntan captura y DOM desde `driver`. */
40
- export function withDriver(driver, opts = {}) {
41
- return {
42
- ...opts,
43
- async onFailure(t, err) {
44
- t.screenshot(await screenshot(driver), "Captura al fallar", "FAIL");
45
- t.dom(await captureDom(driver));
46
- if (opts.onFailure) await opts.onFailure(t, err);
47
- },
48
- };
49
- }
1
+ // Selenium WebDriver (selenium-webdriver) con TraceReports. Sirve con cualquier runner
2
+ // (node:test, Mocha, Jest…): no importa selenium-webdriver, usa el `driver` que le pases.
3
+ //
4
+ // import { TraceReports } from "tracereports";
5
+ // import { withDriver, screenshot } from "tracereports/selenium";
6
+ //
7
+ // const cr = new TraceReports();
8
+ // await cr.startRun("Regresión web", { environment: "chrome" });
9
+ // await cr.test("Login correcto", withDriver(driver, { key: "login.test.js > ok" }), async (t) => {
10
+ // await driver.get(URL);
11
+ // t.screenshot(await screenshot(driver), "Formulario de login");
12
+ // });
13
+ // await cr.finishRun();
14
+ //
15
+ // Si el test falla, `withDriver` adjunta la captura del momento y el snapshot del DOM (con él el
16
+ // reporte recomienda selectores cuando se rompe un locator). Selenium no expone la red del
17
+ // navegador: para la pestaña "Red" usa Playwright o envía las llamadas con `t.network([...])`.
18
+
19
+ import { DOM_SCRIPT } from "./dom.js";
20
+
21
+ /** Captura de pantalla como base64 (lo que acepta `t.screenshot`), o null. */
22
+ export async function screenshot(driver) {
23
+ try {
24
+ return await driver.takeScreenshot();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** Snapshot de los elementos de la página, o null si el navegador ya no responde. */
31
+ export async function captureDom(driver) {
32
+ try {
33
+ return await driver.executeScript(DOM_SCRIPT);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** Opciones para `cr.test(...)` que, al fallar, adjuntan captura y DOM desde `driver`. */
40
+ export function withDriver(driver, opts = {}) {
41
+ return {
42
+ ...opts,
43
+ async onFailure(t, err) {
44
+ t.screenshot(await screenshot(driver), "Captura al fallar", "FAIL");
45
+ t.dom(await captureDom(driver));
46
+ if (opts.onFailure) await opts.onFailure(t, err);
47
+ },
48
+ };
49
+ }
package/src/transport.js CHANGED
@@ -1,157 +1,157 @@
1
- // Transporte del cliente: la evidencia sale en segundo plano, sin frenar los tests.
2
- //
3
- // - Cola acotada (cantidad y bytes) que se envía en orden, de a un evento.
4
- // - Reintentos con backoff ante caídas de red, timeouts, 5xx, 408 y 429. Cada envío lleva una
5
- // Idempotency-Key: si el servidor ya lo aplicó, el reintento no lo duplica.
6
- // - Circuito: si el servidor no responde, las llamadas que necesitan respuesta (crear ejecución o
7
- // test) fallan al instante durante un rato en vez de esperar su timeout en cada test.
8
- // - Cola llena: los eventos nuevos se descartan (se conservan los ya encolados) y se cuentan.
9
- //
10
- // A diferencia del cliente Python no hay spool en disco: lo que no se pudo enviar al cerrar se
11
- // informa en `delivery.lost`.
12
-
13
- import { randomUUID } from "node:crypto";
14
-
15
- const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
16
- export const limits = { circuitMs: 30_000, maxBackoffMs: 30_000 };
17
-
18
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
-
20
- export class Sender {
21
- /**
22
- * @param {string} baseUrl
23
- * @param {() => Record<string,string>} headers
24
- */
25
- constructor(baseUrl, headers, { maxItems = 5000, maxBytes = 64 << 20 } = {}) {
26
- this.baseUrl = baseUrl;
27
- this.headers = headers;
28
- this.maxItems = maxItems;
29
- this.maxBytes = maxBytes;
30
- this.queue = [];
31
- this.bytes = 0;
32
- this.inflight = null;
33
- this.downUntil = 0;
34
- this.running = false;
35
- this.waiters = [];
36
- this.stats = { sent: 0, retried: 0, rejected: 0, dropped: 0, lost: 0 };
37
- }
38
-
39
- serverDown() {
40
- return Date.now() < this.downUntil;
41
- }
42
-
43
- get pending() {
44
- return this.queue.length + (this.inflight ? 1 : 0);
45
- }
46
-
47
- /** Un intento. Devuelve {kind: "ok"|"retry"|"reject", data}. */
48
- async post(item) {
49
- try {
50
- const res = await fetch(this.baseUrl + item.path, {
51
- method: item.method,
52
- body: item.body,
53
- headers: { ...this.headers(), "Content-Type": item.contentType, Accept: "application/json", "Idempotency-Key": item.key },
54
- signal: AbortSignal.timeout(item.timeout),
55
- });
56
- const text = await res.text();
57
- if (res.ok) {
58
- this.downUntil = 0;
59
- return { kind: "ok", data: text ? JSON.parse(text) : {} };
60
- }
61
- if (RETRYABLE.has(res.status)) return { kind: "retry" };
62
- const hint = res.status === 401 ? " (configura el token: new TraceReports({ token }), la variable TRACEREPORTS_TOKEN o TRACEREPORTS_TOKEN en el .env del proyecto)" : "";
63
- console.warn(`tracereports: ${item.method} ${item.path} -> HTTP ${res.status} ${text.slice(0, 300)}${hint}`);
64
- return { kind: "reject" };
65
- } catch (err) {
66
- if (!this.serverDown()) console.warn(`tracereports: ${item.method} ${item.path} falló: ${err.cause?.code || err.message}`);
67
- this.downUntil = Date.now() + limits.circuitMs;
68
- return { kind: "retry" };
69
- }
70
- }
71
-
72
- /** Llamada cuya respuesta se necesita (ids): reintentos cortos, misma Idempotency-Key. */
73
- async sendNow(method, path, body, contentType, timeout, retries = 2, { ignoreCircuit = false } = {}) {
74
- if (this.serverDown() && !ignoreCircuit) return null;
75
- const item = { method, path, body, contentType, timeout, key: randomUUID() };
76
- for (let attempt = 0; attempt <= retries; attempt++) {
77
- const { kind, data } = await this.post(item);
78
- if (kind === "ok") {
79
- this.stats.sent++;
80
- return data;
81
- }
82
- if (kind === "reject" || (this.serverDown() && !ignoreCircuit) || attempt === retries) break;
83
- this.stats.retried++;
84
- await sleep([300, 1000, 2000][Math.min(attempt, 2)]);
85
- }
86
- return null;
87
- }
88
-
89
- /** Encola un evento de evidencia. false si la cola está llena (se cuenta en stats.dropped). */
90
- enqueue(method, path, body, contentType, timeout) {
91
- const size = body?.length ?? 0;
92
- if (this.pending + 1 > this.maxItems || this.bytes + size > this.maxBytes) {
93
- if (this.stats.dropped++ === 0) {
94
- console.warn(`tracereports: la cola de envío está llena (${this.maxItems} eventos); se descartan los eventos nuevos`);
95
- }
96
- return false;
97
- }
98
- this.queue.push({ method, path, body, contentType, timeout, key: randomUUID(), attempts: 0, size });
99
- this.bytes += size;
100
- if (!this.running) this.run();
101
- return true;
102
- }
103
-
104
- async run() {
105
- this.running = true;
106
- while (this.queue.length) {
107
- const item = this.queue.shift();
108
- this.inflight = item;
109
- let kind;
110
- try {
111
- ({ kind } = await this.post(item));
112
- } catch {
113
- kind = "retry";
114
- }
115
- this.inflight = null;
116
- if (item.abandoned) continue; // flush venció y se contó como perdido
117
- if (kind === "retry") {
118
- item.attempts++;
119
- this.stats.retried++;
120
- this.queue.unshift(item);
121
- await sleep(Math.min(limits.maxBackoffMs, 500 * 2 ** Math.min(item.attempts, 6)));
122
- continue;
123
- }
124
- this.bytes -= item.size;
125
- this.stats[kind === "ok" ? "sent" : "rejected"]++;
126
- this.notify();
127
- }
128
- this.running = false;
129
- this.notify();
130
- }
131
-
132
- notify() {
133
- if (this.pending) return;
134
- for (const w of this.waiters.splice(0)) w();
135
- }
136
-
137
- /** Espera a que la cola se vacíe (máx. timeoutMs). Devuelve lo que quedó pendiente. */
138
- async flush(timeoutMs) {
139
- if (!this.pending) return 0;
140
- await Promise.race([new Promise((r) => this.waiters.push(r)), sleep(timeoutMs)]);
141
- return this.pending;
142
- }
143
-
144
- /** Descarta lo pendiente (al cerrar, tras el flush) y lo cuenta como perdido. */
145
- abandon() {
146
- const items = [...this.queue, ...(this.inflight ? [this.inflight] : [])];
147
- for (const it of items) it.abandoned = true;
148
- this.queue.length = 0;
149
- this.inflight = null;
150
- this.bytes = 0;
151
- this.stats.lost += items.length;
152
- if (items.length) {
153
- console.warn(`tracereports: ${items.length} eventos de evidencia no se pudieron enviar y se perdieron`);
154
- }
155
- return items.length;
156
- }
157
- }
1
+ // Transporte del cliente: la evidencia sale en segundo plano, sin frenar los tests.
2
+ //
3
+ // - Cola acotada (cantidad y bytes) que se envía en orden, de a un evento.
4
+ // - Reintentos con backoff ante caídas de red, timeouts, 5xx, 408 y 429. Cada envío lleva una
5
+ // Idempotency-Key: si el servidor ya lo aplicó, el reintento no lo duplica.
6
+ // - Circuito: si el servidor no responde, las llamadas que necesitan respuesta (crear ejecución o
7
+ // test) fallan al instante durante un rato en vez de esperar su timeout en cada test.
8
+ // - Cola llena: los eventos nuevos se descartan (se conservan los ya encolados) y se cuentan.
9
+ //
10
+ // A diferencia del cliente Python no hay spool en disco: lo que no se pudo enviar al cerrar se
11
+ // informa en `delivery.lost`.
12
+
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
16
+ export const limits = { circuitMs: 30_000, maxBackoffMs: 30_000 };
17
+
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ export class Sender {
21
+ /**
22
+ * @param {string} baseUrl
23
+ * @param {() => Record<string,string>} headers
24
+ */
25
+ constructor(baseUrl, headers, { maxItems = 5000, maxBytes = 64 << 20 } = {}) {
26
+ this.baseUrl = baseUrl;
27
+ this.headers = headers;
28
+ this.maxItems = maxItems;
29
+ this.maxBytes = maxBytes;
30
+ this.queue = [];
31
+ this.bytes = 0;
32
+ this.inflight = null;
33
+ this.downUntil = 0;
34
+ this.running = false;
35
+ this.waiters = [];
36
+ this.stats = { sent: 0, retried: 0, rejected: 0, dropped: 0, lost: 0 };
37
+ }
38
+
39
+ serverDown() {
40
+ return Date.now() < this.downUntil;
41
+ }
42
+
43
+ get pending() {
44
+ return this.queue.length + (this.inflight ? 1 : 0);
45
+ }
46
+
47
+ /** Un intento. Devuelve {kind: "ok"|"retry"|"reject", data}. */
48
+ async post(item) {
49
+ try {
50
+ const res = await fetch(this.baseUrl + item.path, {
51
+ method: item.method,
52
+ body: item.body,
53
+ headers: { ...this.headers(), "Content-Type": item.contentType, Accept: "application/json", "Idempotency-Key": item.key },
54
+ signal: AbortSignal.timeout(item.timeout),
55
+ });
56
+ const text = await res.text();
57
+ if (res.ok) {
58
+ this.downUntil = 0;
59
+ return { kind: "ok", data: text ? JSON.parse(text) : {} };
60
+ }
61
+ if (RETRYABLE.has(res.status)) return { kind: "retry" };
62
+ const hint = res.status === 401 ? " (configura el token: new TraceReports({ token }), la variable TRACEREPORTS_TOKEN o TRACEREPORTS_TOKEN en el .env del proyecto)" : "";
63
+ console.warn(`tracereports: ${item.method} ${item.path} -> HTTP ${res.status} ${text.slice(0, 300)}${hint}`);
64
+ return { kind: "reject" };
65
+ } catch (err) {
66
+ if (!this.serverDown()) console.warn(`tracereports: ${item.method} ${item.path} falló: ${err.cause?.code || err.message}`);
67
+ this.downUntil = Date.now() + limits.circuitMs;
68
+ return { kind: "retry" };
69
+ }
70
+ }
71
+
72
+ /** Llamada cuya respuesta se necesita (ids): reintentos cortos, misma Idempotency-Key. */
73
+ async sendNow(method, path, body, contentType, timeout, retries = 2, { ignoreCircuit = false } = {}) {
74
+ if (this.serverDown() && !ignoreCircuit) return null;
75
+ const item = { method, path, body, contentType, timeout, key: randomUUID() };
76
+ for (let attempt = 0; attempt <= retries; attempt++) {
77
+ const { kind, data } = await this.post(item);
78
+ if (kind === "ok") {
79
+ this.stats.sent++;
80
+ return data;
81
+ }
82
+ if (kind === "reject" || (this.serverDown() && !ignoreCircuit) || attempt === retries) break;
83
+ this.stats.retried++;
84
+ await sleep([300, 1000, 2000][Math.min(attempt, 2)]);
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** Encola un evento de evidencia. false si la cola está llena (se cuenta en stats.dropped). */
90
+ enqueue(method, path, body, contentType, timeout) {
91
+ const size = body?.length ?? 0;
92
+ if (this.pending + 1 > this.maxItems || this.bytes + size > this.maxBytes) {
93
+ if (this.stats.dropped++ === 0) {
94
+ console.warn(`tracereports: la cola de envío está llena (${this.maxItems} eventos); se descartan los eventos nuevos`);
95
+ }
96
+ return false;
97
+ }
98
+ this.queue.push({ method, path, body, contentType, timeout, key: randomUUID(), attempts: 0, size });
99
+ this.bytes += size;
100
+ if (!this.running) this.run();
101
+ return true;
102
+ }
103
+
104
+ async run() {
105
+ this.running = true;
106
+ while (this.queue.length) {
107
+ const item = this.queue.shift();
108
+ this.inflight = item;
109
+ let kind;
110
+ try {
111
+ ({ kind } = await this.post(item));
112
+ } catch {
113
+ kind = "retry";
114
+ }
115
+ this.inflight = null;
116
+ if (item.abandoned) continue; // flush venció y se contó como perdido
117
+ if (kind === "retry") {
118
+ item.attempts++;
119
+ this.stats.retried++;
120
+ this.queue.unshift(item);
121
+ await sleep(Math.min(limits.maxBackoffMs, 500 * 2 ** Math.min(item.attempts, 6)));
122
+ continue;
123
+ }
124
+ this.bytes -= item.size;
125
+ this.stats[kind === "ok" ? "sent" : "rejected"]++;
126
+ this.notify();
127
+ }
128
+ this.running = false;
129
+ this.notify();
130
+ }
131
+
132
+ notify() {
133
+ if (this.pending) return;
134
+ for (const w of this.waiters.splice(0)) w();
135
+ }
136
+
137
+ /** Espera a que la cola se vacíe (máx. timeoutMs). Devuelve lo que quedó pendiente. */
138
+ async flush(timeoutMs) {
139
+ if (!this.pending) return 0;
140
+ await Promise.race([new Promise((r) => this.waiters.push(r)), sleep(timeoutMs)]);
141
+ return this.pending;
142
+ }
143
+
144
+ /** Descarta lo pendiente (al cerrar, tras el flush) y lo cuenta como perdido. */
145
+ abandon() {
146
+ const items = [...this.queue, ...(this.inflight ? [this.inflight] : [])];
147
+ for (const it of items) it.abandoned = true;
148
+ this.queue.length = 0;
149
+ this.inflight = null;
150
+ this.bytes = 0;
151
+ this.stats.lost += items.length;
152
+ if (items.length) {
153
+ console.warn(`tracereports: ${items.length} eventos de evidencia no se pudieron enviar y se perdieron`);
154
+ }
155
+ return items.length;
156
+ }
157
+ }