tracereports 0.1.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/README.es.md ADDED
@@ -0,0 +1,13 @@
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).
package/README.md ADDED
@@ -0,0 +1,13 @@
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).
package/package.json ADDED
@@ -0,0 +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
+ }
package/src/context.js ADDED
@@ -0,0 +1,33 @@
1
+ // Contexto de la ejecución: rama y commit. El servidor solo compara una ejecución con otras del
2
+ // mismo proyecto, ambiente y rama. Se leen de las variables de los CI más comunes o de git.
3
+
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ const BRANCH_VARS = ["TRACEREPORTS_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "BITBUCKET_BRANCH",
7
+ "BUILD_SOURCEBRANCHNAME", "BRANCH_NAME", "CIRCLE_BRANCH", "GIT_BRANCH"];
8
+ const COMMIT_VARS = ["TRACEREPORTS_COMMIT", "GITHUB_SHA", "CI_COMMIT_SHA", "BITBUCKET_COMMIT", "BUILD_SOURCEVERSION", "CIRCLE_SHA1", "GIT_COMMIT"];
9
+
10
+ function git(...args) {
11
+ try {
12
+ return execFileSync("git", args, { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
13
+ } catch {
14
+ return "";
15
+ }
16
+ }
17
+
18
+ function firstEnv(vars) {
19
+ for (const v of vars) {
20
+ const value = (process.env[v] || "").trim();
21
+ if (value) return value.replace(/^origin\//, "");
22
+ }
23
+ return "";
24
+ }
25
+
26
+ export function detectBranch() {
27
+ const branch = firstEnv(BRANCH_VARS) || git("rev-parse", "--abbrev-ref", "HEAD");
28
+ return branch === "HEAD" ? "" : branch;
29
+ }
30
+
31
+ export function detectCommit() {
32
+ return firstEnv(COMMIT_VARS) || git("rev-parse", "HEAD");
33
+ }
package/src/dom.js ADDED
@@ -0,0 +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()})();`;
package/src/env.js ADDED
@@ -0,0 +1,95 @@
1
+ // Variables de entorno del cliente: TRACEREPORTS_<NOMBRE>.
2
+ //
3
+ // Si la variable no está en el entorno, se busca en el archivo .env del proyecto (el mismo que lee
4
+ // el servidor): el token vive en un solo lugar, ignorado por git, sin definirlo en cada terminal ni
5
+ // de forma global. Las variables del entorno siempre ganan (en CI llegan como secrets). Del archivo
6
+ // solo se usan las claves TRACEREPORTS_*.
7
+ //
8
+ // El .env se busca desde la carpeta actual hacia arriba, hasta la raíz del repositorio (la carpeta
9
+ // con .git). TRACEREPORTS_ENV_FILE indica otro archivo, u "off" para no leer ninguno.
10
+
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const PREFIX = "TRACEREPORTS_";
15
+
16
+ let fileValues = null;
17
+ let filePath = null;
18
+
19
+ /** Líneas KEY=VALUE con las mismas reglas que el servidor. */
20
+ export function parseEnvFile(file) {
21
+ const values = {};
22
+ let text;
23
+ try {
24
+ text = readFileSync(file, "utf8").replace(/^/, "");
25
+ } catch {
26
+ return values;
27
+ }
28
+ for (const raw of text.split(/\r?\n/)) {
29
+ let line = raw.trim();
30
+ if (!line || line.startsWith("#")) continue;
31
+ if (line.startsWith("export ")) line = line.slice("export ".length);
32
+ const eq = line.indexOf("=");
33
+ if (eq < 0) continue;
34
+ const key = line.slice(0, eq).trim();
35
+ if (!key || /[ \t]/.test(key)) continue;
36
+ let val = line.slice(eq + 1).trim();
37
+ if (val.length >= 2 && val[0] === val[val.length - 1] && (val[0] === '"' || val[0] === "'")) {
38
+ val = val.slice(1, -1);
39
+ } else if (val.includes(" #")) {
40
+ val = val.slice(0, val.indexOf(" #")).trim();
41
+ }
42
+ values[key] = val;
43
+ }
44
+ return values;
45
+ }
46
+
47
+ function isFile(p) {
48
+ try {
49
+ return statSync(p).isFile();
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** El .env que corresponde, o null si no hay o está desactivado. */
56
+ export function findEnvFile(start = process.cwd()) {
57
+ const explicit = process.env[`${PREFIX}ENV_FILE`];
58
+ if (explicit) return explicit.trim().toLowerCase() === "off" ? null : explicit;
59
+ let dir = path.resolve(start);
60
+ for (;;) {
61
+ const candidate = path.join(dir, ".env");
62
+ if (isFile(candidate)) return candidate;
63
+ const parent = path.dirname(dir);
64
+ if (existsSync(path.join(dir, ".git")) || parent === dir) return null;
65
+ dir = parent;
66
+ }
67
+ }
68
+
69
+ function fromFile() {
70
+ if (fileValues === null) {
71
+ filePath = findEnvFile();
72
+ const all = filePath ? parseEnvFile(filePath) : {};
73
+ fileValues = Object.fromEntries(Object.entries(all).filter(([k]) => k.startsWith(PREFIX)));
74
+ }
75
+ return fileValues;
76
+ }
77
+
78
+ /** Ruta del .env del que se leyó alguna variable (para los mensajes), o null. */
79
+ export function envFileInUse() {
80
+ return Object.keys(fromFile()).length ? filePath : null;
81
+ }
82
+
83
+ /** Vuelve a buscar el .env en la próxima lectura (tests). */
84
+ export function resetEnvFile() {
85
+ fileValues = null;
86
+ filePath = null;
87
+ }
88
+
89
+ /** TRACEREPORTS_<name>: primero del entorno, después del .env del proyecto. */
90
+ export function env(name, fallback = undefined) {
91
+ for (const v of [process.env[PREFIX + name], fromFile()[PREFIX + name]]) {
92
+ if (v !== undefined && v !== "") return v;
93
+ }
94
+ return fallback;
95
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ // Tipos de tracereports (cliente JavaScript).
2
+
3
+ export interface Delivery {
4
+ sent: number; retried: number; rejected: number; dropped: number; lost: number; pending: number; unregisteredTests: number;
5
+ /** 1 si el servidor no confirmó el cierre de la ejecución. */
6
+ runNotClosed: number;
7
+ }
8
+
9
+ export interface TraceReportsOptions {
10
+ baseUrl?: string; token?: string; enabled?: boolean; timeoutMs?: number; uploadTimeoutMs?: number;
11
+ flushTimeoutMs?: number; maxQueueItems?: number; maxQueueMB?: number;
12
+ }
13
+
14
+ export interface RunOptions { environment?: string; project?: string; branch?: string; commit?: string; framework?: string }
15
+
16
+ export interface TestOptions {
17
+ category?: string; description?: string; key?: string; suite?: string; params?: string; worker?: string | number;
18
+ status?: string; onFailure?: (t: TraceTest, err: unknown) => unknown | Promise<unknown>;
19
+ }
20
+
21
+ export type Status = "INFO" | "PASS" | "FAIL" | "WARNING" | "SKIP";
22
+
23
+ export interface Connection {
24
+ method: string; url: string; status?: number; status_text?: string; mime_type?: string; resource_type?: string;
25
+ failed?: boolean; error_text?: string; started_at?: number; duration_ms?: number | null;
26
+ request_headers?: Record<string, string>; post_data?: string; response_headers?: Record<string, string>;
27
+ response_body?: string; body_size?: number; expected?: boolean;
28
+ }
29
+
30
+ export class TraceReports {
31
+ constructor(opts?: TraceReportsOptions);
32
+ baseUrl: string; runId: number | null; runCreated: boolean; enabled: boolean;
33
+ readonly delivery: Delivery;
34
+ readonly reportUrl: string;
35
+ deliveryProblems(): number;
36
+ startRun(name: string, opts?: RunOptions): Promise<number | null>;
37
+ joinRun(runId: number | string): number | null;
38
+ finishRun(opts?: { interrupted?: boolean }): Promise<unknown>;
39
+ flush(timeoutMs?: number): Promise<number>;
40
+ startTest(name: string, opts?: TestOptions): Promise<TraceTest>;
41
+ test<T>(name: string, fn: (t: TraceTest) => T | Promise<T>): Promise<T>;
42
+ test<T>(name: string, opts: TestOptions, fn: (t: TraceTest) => T | Promise<T>): Promise<T>;
43
+ }
44
+
45
+ export class TraceTest {
46
+ readonly id: number | null; readonly active: boolean; attempts: number;
47
+ log(status: Status, message: string, timestamp?: number): void;
48
+ info(message: string): void; pass(message: string): void; fail(message: string): void; warn(message: string): void; skip(message: string): void;
49
+ screenshot(image: Uint8Array | string | null, message?: string, status?: Status): void;
50
+ expectResponse(status: number | number[], opts?: { url?: string; method?: string }): void;
51
+ network(connections: Connection[], opts?: { maxBodyKB?: number; batchSize?: number }): { stored: number; errors: number };
52
+ dom(snapshot: unknown): void;
53
+ finish(status?: Status | "", opts?: { errorMessage?: string; errorTrace?: string; error?: unknown; attempts?: number }): void;
54
+ }
55
+
56
+ export const DOM_SCRIPT: string;
57
+
58
+ export function domSnapshotInPage(): unknown;
package/src/index.js ADDED
@@ -0,0 +1,296 @@
1
+ // Cliente de TraceReports para JavaScript/TypeScript (Node 18+, sin dependencias).
2
+ //
3
+ // import { TraceReports } from "tracereports";
4
+ //
5
+ // const cr = new TraceReports(); // $TRACEREPORTS_URL o http://localhost:8080
6
+ // await cr.startRun("Regresión web", { environment: "staging" });
7
+ // const t = await cr.startTest("Login", { key: "login.spec.js > admin entra", category: "smoke" });
8
+ // t.info("Abrir el login");
9
+ // t.screenshot(await page.screenshot(), "Formulario"); // Playwright (Buffer)
10
+ // t.screenshot(await driver.takeScreenshot(), "Form"); // Selenium (base64)
11
+ // t.finish("PASS");
12
+ // await cr.finishRun(); // espera la cola y cierra la ejecución
13
+ //
14
+ // Nunca rompe ni frena la suite: la evidencia sale en segundo plano con reintentos e
15
+ // Idempotency-Key (sin duplicados); si el servidor no responde, los métodos siguen funcionando
16
+ // como no-op y `cr.delivery` dice qué no llegó.
17
+
18
+ import path from "node:path";
19
+ import { detectBranch, detectCommit } from "./context.js";
20
+ import { env } from "./env.js";
21
+ import { Sender } from "./transport.js";
22
+
23
+ export const VERSION = "0.1.0";
24
+ const STATUSES = new Set(["INFO", "PASS", "FAIL", "WARNING", "SKIP"]);
25
+ const truthy = (v) => ["1", "true", "yes"].includes(String(v || "").toLowerCase());
26
+
27
+ export class TraceReports {
28
+ /**
29
+ * @param {object} [opts]
30
+ * @param {string} [opts.baseUrl] default $TRACEREPORTS_URL o http://localhost:8080
31
+ * @param {string} [opts.token] token de la API (TRACEREPORTS_TOKEN del servidor)
32
+ * @param {boolean} [opts.enabled] false (o $TRACEREPORTS_DISABLED=1) apaga todo
33
+ * @param {number} [opts.timeoutMs] por request JSON (default 3000)
34
+ * @param {number} [opts.uploadTimeoutMs] por captura o lote de red (default 15000)
35
+ * @param {number} [opts.flushTimeoutMs] cuánto espera finishRun la cola (default $TRACEREPORTS_FLUSH_TIMEOUT s o 30 s)
36
+ * @param {number} [opts.maxQueueItems] default 5000
37
+ * @param {number} [opts.maxQueueMB] default 64
38
+ */
39
+ constructor(opts = {}) {
40
+ this.baseUrl = (opts.baseUrl || env("URL") || "http://localhost:8080").replace(/\/+$/, "");
41
+ this.token = opts.token ?? env("TOKEN", "");
42
+ this.enabled = (opts.enabled ?? true) && !truthy(env("DISABLED"));
43
+ this.timeoutMs = opts.timeoutMs ?? 3000;
44
+ this.uploadTimeoutMs = opts.uploadTimeoutMs ?? 15000;
45
+ this.flushTimeoutMs = opts.flushTimeoutMs ?? (Number(env("FLUSH_TIMEOUT")) * 1000 || 30_000);
46
+ this.sender = new Sender(this.baseUrl, () => this.headers(), {
47
+ maxItems: opts.maxQueueItems ?? 5000,
48
+ maxBytes: (opts.maxQueueMB ?? 64) << 20,
49
+ });
50
+ this.runId = null;
51
+ this.runCreated = false;
52
+ this.runNotClosed = false;
53
+ this.unregisteredTests = 0;
54
+ }
55
+
56
+ headers() {
57
+ const h = { "User-Agent": `tracereports-js/${VERSION}` };
58
+ if (this.token) h.Authorization = `Bearer ${this.token}`;
59
+ return h;
60
+ }
61
+
62
+ /** sent, retried, rejected (4xx), dropped (cola llena), lost, pending y unregisteredTests. */
63
+ /**
64
+ * sent, retried, rejected (4xx), dropped (cola llena), lost, pending, unregisteredTests y
65
+ * runNotClosed (1 si el servidor no confirmó el cierre de la ejecución: quedó abierta).
66
+ */
67
+ get delivery() {
68
+ return { ...this.sender.stats, pending: this.sender.pending, unregisteredTests: this.unregisteredTests,
69
+ runNotClosed: this.runNotClosed ? 1 : 0 };
70
+ }
71
+
72
+ /** Eventos que no llegaron al servidor (incluye un cierre de ejecución no confirmado). */
73
+ deliveryProblems() {
74
+ const d = this.delivery;
75
+ return d.rejected + d.dropped + d.lost + d.pending + d.unregisteredTests + d.runNotClosed;
76
+ }
77
+
78
+ get reportUrl() {
79
+ return this.runId ? `${this.baseUrl}/#run=${this.runId}&view=dashboard` : "";
80
+ }
81
+
82
+ async request(method, apiPath, payload) {
83
+ if (!this.enabled) return null;
84
+ return this.sender.sendNow(method, apiPath, JSON.stringify(payload), "application/json", this.timeoutMs);
85
+ }
86
+
87
+ emit(method, apiPath, body, contentType = "application/json", timeout = this.timeoutMs) {
88
+ if (!this.enabled) return false;
89
+ return this.sender.enqueue(method, apiPath, body, contentType, timeout);
90
+ }
91
+
92
+ // ─── ejecuciones ──────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Crea la ejecución. Con $TRACEREPORTS_RUN_ID se une a una ya creada (shards de CI) y no la cierra.
96
+ * `project` (default $TRACEREPORTS_PROJECT o la carpeta actual), `branch` y `commit` (default: CI o
97
+ * git) definen el contexto: historial y comparaciones solo usan ejecuciones del mismo proyecto,
98
+ * ambiente y rama.
99
+ */
100
+ async startRun(name, { environment = "", project, branch, commit, framework = "" } = {}) {
101
+ if (env("RUN_ID")) return this.joinRun(env("RUN_ID"));
102
+ const res = await this.request("POST", "/api/v1/runs", {
103
+ name,
104
+ environment,
105
+ project: project ?? env("PROJECT") ?? path.basename(process.cwd()),
106
+ branch: branch ?? detectBranch(),
107
+ commit: commit ?? detectCommit(),
108
+ framework,
109
+ });
110
+ this.runId = res?.run_id ?? null;
111
+ this.runCreated = Boolean(this.runId);
112
+ if (this.enabled && !this.runId) {
113
+ console.warn(`tracereports: no se pudo crear la ejecución en ${this.baseUrl}; los tests siguen sin reporte.`);
114
+ }
115
+ return this.runId;
116
+ }
117
+
118
+ /** Reporta en una ejecución creada en otro proceso; quien la creó la cierra. */
119
+ joinRun(runId) {
120
+ this.runId = runId ? Number(runId) : null;
121
+ this.runCreated = false;
122
+ return this.runId;
123
+ }
124
+
125
+ /** Espera la cola (flushTimeoutMs) y cierra la ejecución. interrupted: incompleta, nunca verde. */
126
+ async finishRun({ interrupted = false } = {}) {
127
+ await this.flush();
128
+ this.sender.abandon();
129
+ if (!this.runId || !this.runCreated || !this.enabled) return null; // unido a otra: la cierra su dueño
130
+ // el cierre importa más que un paso: más reintentos, siempre con la misma Idempotency-Key
131
+ const res = await this.sender.sendNow("PATCH", `/api/v1/runs/${this.runId}/finish`,
132
+ JSON.stringify({ interrupted }), "application/json", this.timeoutMs, 4, { ignoreCircuit: true });
133
+ this.runNotClosed = res == null;
134
+ if (this.runNotClosed) {
135
+ console.warn(`tracereports: el servidor no confirmó el cierre de la ejecución #${this.runId}: quedó abierta ` +
136
+ `(ciérrala con PATCH ${this.baseUrl}/api/v1/runs/${this.runId}/finish)`);
137
+ }
138
+ return res;
139
+ }
140
+
141
+ /** Espera a que la evidencia encolada se envíe. Devuelve lo que quedó pendiente. */
142
+ flush(timeoutMs = this.flushTimeoutMs) {
143
+ return this.sender.flush(timeoutMs);
144
+ }
145
+
146
+ // ─── tests ────────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Inicia un test. `key` es su identidad estable dentro del proyecto (archivo + título): el
150
+ * historial y la detección de flaky la siguen. Sin key, el servidor usa el nombre.
151
+ * Devuelve siempre un TraceTest (no-op si el servidor no respondió).
152
+ */
153
+ async startTest(name, { category = "", description = "", key = "", suite = "", params = "", worker = "" } = {}) {
154
+ if (!this.runId) return new TraceTest(this, null);
155
+ const res = await this.request("POST", `/api/v1/runs/${this.runId}/tests`,
156
+ { name, category, description, key, suite, params, worker: String(worker ?? "") });
157
+ if (this.enabled && !res?.test_id) this.unregisteredTests++;
158
+ return new TraceTest(this, res?.test_id ?? null);
159
+ }
160
+
161
+ /**
162
+ * Corre `fn` como un test: lo inicia, lo cierra según el resultado y, si falla, adjunta la
163
+ * captura y el snapshot de `onFailure` (opcional). El error se vuelve a lanzar.
164
+ */
165
+ async test(name, opts, fn) {
166
+ if (typeof opts === "function") [fn, opts] = [opts, {}];
167
+ const t = await this.startTest(name, opts);
168
+ try {
169
+ const out = await fn(t);
170
+ t.finish(opts.status);
171
+ return out;
172
+ } catch (err) {
173
+ if (opts.onFailure) {
174
+ try { await opts.onFailure(t, err); } catch { /* la evidencia nunca rompe el test */ }
175
+ }
176
+ t.fail(`${err?.name || "Error"}: ${err?.message ?? err}`);
177
+ t.finish("FAIL", { error: err });
178
+ throw err;
179
+ }
180
+ }
181
+ }
182
+
183
+ export class TraceTest {
184
+ constructor(cr, id) {
185
+ this.cr = cr;
186
+ this.id = id;
187
+ this.expected = [];
188
+ this.attempts = 1;
189
+ }
190
+
191
+ get active() {
192
+ return Boolean(this.id && this.cr.enabled);
193
+ }
194
+
195
+ /** Paso del test. `timestamp` (ms) permite registrar la hora real en que ocurrió. */
196
+ log(status, message, timestamp = Date.now()) {
197
+ status = String(status || "INFO").toUpperCase();
198
+ if (!this.active || !STATUSES.has(status)) return;
199
+ this.cr.emit("POST", `/api/v1/tests/${this.id}/logs`,
200
+ JSON.stringify({ status, message: String(message), timestamp: Math.round(timestamp) }));
201
+ }
202
+
203
+ info(m) { this.log("INFO", m); }
204
+ pass(m) { this.log("PASS", m); }
205
+ fail(m) { this.log("FAIL", m); }
206
+ warn(m) { this.log("WARNING", m); }
207
+ skip(m) { this.log("SKIP", m); }
208
+
209
+ /**
210
+ * Captura como paso. Acepta un Buffer/Uint8Array (Playwright `page.screenshot()`) o un string
211
+ * base64 (Selenium `driver.takeScreenshot()`).
212
+ */
213
+ screenshot(image, message = "", status = "INFO") {
214
+ if (!this.active || !image) return;
215
+ const data = typeof image === "string" ? Buffer.from(image, "base64") : Buffer.from(image);
216
+ const boundary = `----tracereports${Math.random().toString(16).slice(2)}`;
217
+ const part = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
218
+ const body = Buffer.concat([
219
+ Buffer.from(part("message", message) + part("status", String(status).toUpperCase())),
220
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="screenshot.png"\r\nContent-Type: image/png\r\n\r\n`),
221
+ data,
222
+ Buffer.from(`\r\n--${boundary}--\r\n`),
223
+ ]);
224
+ this.cr.emit("POST", `/api/v1/tests/${this.id}/screenshot`, body, `multipart/form-data; boundary=${boundary}`, this.cr.uploadTimeoutMs);
225
+ }
226
+
227
+ /**
228
+ * Declara una respuesta negativa que el test verifica a propósito (p. ej. un 401 con
229
+ * credenciales inválidas): no cuenta como error ni como causa del fallo. `url`: texto contenido
230
+ * o glob con `*`.
231
+ */
232
+ expectResponse(status, { url = "", method = "" } = {}) {
233
+ this.expected.push({ status: [].concat(status).map(Number), url, method: method.toUpperCase() });
234
+ }
235
+
236
+ isExpected(c) {
237
+ return this.expected.some((r) => {
238
+ if (!r.status.includes(Number(c.status || 0))) return false;
239
+ if (r.method && (c.method || "").toUpperCase() !== r.method) return false;
240
+ if (!r.url) return true;
241
+ if (!r.url.includes("*")) return (c.url || "").includes(r.url);
242
+ const re = new RegExp("^" + r.url.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
243
+ return re.test(c.url || "");
244
+ });
245
+ }
246
+
247
+ /** Llamadas de red del test (ver `tracereports/playwright`). Antes de finish. */
248
+ network(connections, { maxBodyKB = 256, batchSize = 200 } = {}) {
249
+ if (!this.active || !connections?.length) return { stored: 0, errors: 0 };
250
+ const limit = maxBodyKB * 1024;
251
+ const payload = connections.map((c) => {
252
+ const body = String(c.response_body ?? "");
253
+ return {
254
+ method: c.method || "", url: c.url || "", status: Number(c.status || 0), status_text: c.status_text || "",
255
+ mime_type: c.mime_type || "", resource_type: c.resource_type || "", failed: Boolean(c.failed),
256
+ error_text: String(c.error_text || ""), started_at: Math.round(c.started_at || 0),
257
+ duration_ms: c.duration_ms == null ? null : Math.round(c.duration_ms),
258
+ request_headers: c.request_headers || {}, post_data: String(c.post_data || ""),
259
+ response_headers: c.response_headers || {}, response_body: body.slice(0, limit),
260
+ body_size: Math.max(Number(c.body_size || 0), body.length), body_truncated: body.length > limit,
261
+ expected: Boolean(c.expected) || this.isExpected(c),
262
+ };
263
+ });
264
+ for (let i = 0; i < payload.length; i += batchSize) {
265
+ this.cr.emit("POST", `/api/v1/tests/${this.id}/network`, JSON.stringify({ connections: payload.slice(i, i + batchSize) }),
266
+ "application/json", this.cr.uploadTimeoutMs);
267
+ }
268
+ return { stored: payload.length, errors: payload.filter((p) => !p.expected && (p.failed || p.status >= 400)).length };
269
+ }
270
+
271
+ /** Snapshot de la página al fallar (recomendador de locators). Antes de finish. */
272
+ dom(snapshot) {
273
+ if (!this.active || !snapshot) return;
274
+ this.cr.emit("POST", `/api/v1/tests/${this.id}/dom`, JSON.stringify(snapshot), "application/json", this.cr.uploadTimeoutMs);
275
+ }
276
+
277
+ /**
278
+ * Cierra el test. status: PASS | FAIL | WARNING | SKIP, o vacío para deducirlo de los pasos.
279
+ * Un FAIL dispara el diagnóstico con IA. `attempts` > 1: reintentos (pasó tras reintento).
280
+ */
281
+ finish(status = "", { errorMessage = "", errorTrace = "", error, attempts } = {}) {
282
+ if (!this.active) return;
283
+ if (error) {
284
+ errorMessage ||= `${error.name || "Error"}: ${error.message ?? error}`;
285
+ errorTrace ||= String(error.stack || "");
286
+ status ||= "FAIL";
287
+ }
288
+ const payload = { status: String(status || "").toUpperCase(), error_message: errorMessage, error_trace: errorTrace };
289
+ const n = attempts ?? this.attempts;
290
+ if (n > 1) payload.attempts = n;
291
+ this.cr.emit("PATCH", `/api/v1/tests/${this.id}/finish`, JSON.stringify(payload));
292
+ }
293
+ }
294
+
295
+ export { DOM_SCRIPT, domSnapshotInPage } from "./dom.js";
296
+
@@ -0,0 +1,156 @@
1
+ // Playwright Test: fixtures que capturan la evidencia sin cambiar los tests.
2
+ //
3
+ // // en tus specs, en vez de "@playwright/test":
4
+ // import { test, expect } from "tracereports/playwright";
5
+ //
6
+ // test("login inválido", async ({ page, tracereports }) => {
7
+ // tracereports.expectResponse(401, { url: "*/auth/validate*" }); // negativo esperado
8
+ // tracereports.info("Abrir el login");
9
+ // ...
10
+ // });
11
+ //
12
+ // Con la `page` del test:
13
+ // - captura las llamadas HTTP que hace el navegador (pestaña "Red"; es el tráfico visto desde el
14
+ // navegador, no los logs del backend);
15
+ // - si el test falla: snapshot del DOM (recomendador de locators) y una captura si el proyecto
16
+ // no tiene `screenshot: "only-on-failure"`.
17
+ // Todo viaja como attachments del test; el reporter `tracereports/reporter` los sube.
18
+
19
+ import { createRequire } from "node:module";
20
+ import path from "node:path";
21
+ import { pathToFileURL } from "node:url";
22
+ import { domSnapshotInPage } from "./dom.js";
23
+
24
+ // @playwright/test se carga desde el proyecto que corre los tests: así hay una sola copia aunque
25
+ // este paquete esté instalado como enlace (npm install ../tracereports/client/js).
26
+ async function loadPlaywrightTest() {
27
+ try {
28
+ const req = createRequire(path.join(process.cwd(), "package.json"));
29
+ return await import(pathToFileURL(req.resolve("@playwright/test")).href);
30
+ } catch {
31
+ return import("@playwright/test");
32
+ }
33
+ }
34
+ const pw = await loadPlaywrightTest();
35
+ const base = pw.test ?? pw.default?.test;
36
+ const expect = pw.expect ?? pw.default?.expect;
37
+
38
+ const BODY_TYPES = new Set(["xhr", "fetch"]);
39
+ const MAX_BODY = 256 * 1024;
40
+
41
+ /**
42
+ * Engancha la captura de red a una página (antes de navegar). Devuelve un colector con
43
+ * `settle(ms)` (espera los bodies en lectura) y `drain()` (conexiones nuevas desde el último drain).
44
+ */
45
+ export function captureNetwork(page, { apiPatterns = ["/api/"] } = {}) {
46
+ const conns = [];
47
+ const byRequest = new Map();
48
+ const pending = new Set();
49
+ let reported = 0;
50
+
51
+ page.on("request", (req) => {
52
+ const c = {
53
+ method: req.method(), url: req.url(), resource_type: req.resourceType(),
54
+ request_headers: req.headers(), post_data: req.postData() || "", started_at: Date.now(), status: 0,
55
+ };
56
+ conns.push(c);
57
+ byRequest.set(req, c);
58
+ });
59
+ page.on("response", (res) => {
60
+ const c = byRequest.get(res.request());
61
+ if (!c) return;
62
+ c.status = res.status();
63
+ c.status_text = res.statusText();
64
+ c.response_headers = res.headers();
65
+ c.mime_type = res.headers()["content-type"] || "";
66
+ const isApi = BODY_TYPES.has(c.resource_type) || apiPatterns.some((p) => c.url.includes(p));
67
+ if (isApi || c.status >= 400) {
68
+ const p = res.text()
69
+ .then((body) => { c.body_size = body.length; c.response_body = body.slice(0, MAX_BODY); })
70
+ .catch(() => {})
71
+ .finally(() => pending.delete(p));
72
+ pending.add(p);
73
+ }
74
+ });
75
+ page.on("requestfinished", (req) => {
76
+ const c = byRequest.get(req);
77
+ if (!c) return;
78
+ const t = req.timing();
79
+ c.duration_ms = t && t.responseEnd > 0 ? Math.round(t.responseEnd) : Date.now() - c.started_at;
80
+ });
81
+ page.on("requestfailed", (req) => {
82
+ const c = byRequest.get(req);
83
+ if (!c) return;
84
+ c.failed = true;
85
+ c.error_text = req.failure()?.errorText || "";
86
+ c.duration_ms = Date.now() - c.started_at;
87
+ });
88
+
89
+ return {
90
+ connections: conns,
91
+ async settle(ms = 2000) {
92
+ await Promise.race([Promise.allSettled([...pending]), new Promise((r) => setTimeout(r, ms))]);
93
+ },
94
+ drain() {
95
+ const out = conns.slice(reported);
96
+ reported = conns.length;
97
+ return out;
98
+ },
99
+ };
100
+ }
101
+
102
+ /** Snapshot de los elementos de la página, o null si ya no está disponible. */
103
+ export async function captureDom(page) {
104
+ try {
105
+ return await page.evaluate(domSnapshotInPage);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const note = (testInfo, type, data) => testInfo.annotations.push({ type, description: JSON.stringify(data) });
112
+
113
+ /**
114
+ * Agrega los fixtures de TraceReports a un `test` de Playwright (el tuyo, ya extendido o no):
115
+ * import { test as base } from "@playwright/test";
116
+ * export const test = withTraceReports(base);
117
+ */
118
+ export function withTraceReports(baseTest) {
119
+ return baseTest.extend(fixtures);
120
+ }
121
+
122
+ const fixtures = {
123
+ page: async ({ page }, use, testInfo) => {
124
+ const net = captureNetwork(page);
125
+ await use(page);
126
+ await net.settle(2000).catch(() => {});
127
+ const conns = net.drain();
128
+ if (conns.length) {
129
+ await testInfo.attach("tracereports-network", { body: JSON.stringify(conns), contentType: "application/json" });
130
+ }
131
+ if (testInfo.status !== testInfo.expectedStatus) {
132
+ const snap = await captureDom(page);
133
+ if (snap) await testInfo.attach("tracereports-dom", { body: JSON.stringify(snap), contentType: "application/json" });
134
+ const mode = testInfo.project.use?.screenshot;
135
+ const auto = mode === "on" || mode === "only-on-failure" || mode?.mode === "on" || mode?.mode === "only-on-failure";
136
+ if (!auto) {
137
+ try {
138
+ await testInfo.attach("Captura al fallar", { body: await page.screenshot({ timeout: 5000 }), contentType: "image/png" });
139
+ } catch { /* la página puede estar cerrada */ }
140
+ }
141
+ }
142
+ },
143
+
144
+ /** Pasos propios y respuestas esperadas (el reporter los manda a TraceReports). */
145
+ tracereports: async ({}, use, testInfo) => {
146
+ const log = (status) => (message) => note(testInfo, "tracereports-log", { status, message: String(message), timestamp: Date.now() });
147
+ await use({
148
+ info: log("INFO"), pass: log("PASS"), fail: log("FAIL"), warn: log("WARNING"),
149
+ expectResponse: (status, { url = "", method = "" } = {}) => note(testInfo, "tracereports-expect", { status, url, method }),
150
+ });
151
+ },
152
+ };
153
+
154
+ export const test = withTraceReports(base);
155
+
156
+ export { expect };
@@ -0,0 +1,181 @@
1
+ // Reporter de Playwright Test para TraceReports.
2
+ //
3
+ // // playwright.config.js
4
+ // reporter: [["list"], ["tracereports/reporter", { runName: "Regresión web", environment: "staging" }]],
5
+ //
6
+ // - Una ejecución por corrida (todos los workers reportan aquí: el reporter vive en el proceso
7
+ // principal). Con --shard en varias máquinas, TRACEREPORTS_RUN_ID une los shards a una ejecución.
8
+ // - Identidad estable de cada test: archivo + títulos (+ proyecto de Playwright), aparte del nombre.
9
+ // - Reintentos (`retries`): los intentos quedan en el mismo test ("pasó tras reintento").
10
+ // - Pasos de `test.step`, error y stack, capturas adjuntas (`screenshot: "only-on-failure"` o
11
+ // testInfo.attach con imagen) y, con los fixtures de `tracereports/playwright`, la red y el DOM.
12
+ // - Al terminar espera la cola y cierra la ejecución; imprime el link. TRACEREPORTS_STRICT=1 hace fallar
13
+ // la corrida si parte de la evidencia no llegó al servidor.
14
+ //
15
+ // Opciones (o variables): url (TRACEREPORTS_URL), token (TRACEREPORTS_TOKEN), runName (TRACEREPORTS_RUN_NAME),
16
+ // environment (TRACEREPORTS_ENV), project (TRACEREPORTS_PROJECT), strict (TRACEREPORTS_STRICT).
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { env } from "./env.js";
21
+ import { TraceReports } from "./index.js";
22
+
23
+ const ANSI = /\u001b\[[0-9;]*m/g;
24
+ const truthy = (v) => ["1", "true", "yes"].includes(String(v || "").toLowerCase());
25
+
26
+ export default class TraceReportsReporter {
27
+ constructor(options = {}) {
28
+ this.options = options;
29
+ this.cr = new TraceReports({ baseUrl: options.url, token: options.token });
30
+ this.tests = new Map(); // test.id -> { ct: Promise<TraceTest>, lastError }
31
+ this.steps = new Map(); // test.id -> intento -> pasos (test.step)
32
+ this.strict = options.strict ?? truthy(env("STRICT"));
33
+ this.chain = Promise.resolve(); // los eventos de cada test se procesan en orden
34
+ }
35
+
36
+ printsToStdio() {
37
+ return false;
38
+ }
39
+
40
+ onBegin(config, suite) {
41
+ // la carpeta del proyecto es la del playwright.config (rootDir es la de los tests)
42
+ this.rootDir = config.configFile ? path.dirname(config.configFile) : config.rootDir;
43
+ const name = this.options.runName || env("RUN_NAME") || path.basename(this.rootDir);
44
+ const projects = [...new Set(config.projects.map((p) => p.name).filter(Boolean))].join(", ");
45
+ const environment = this.options.environment ?? env("ENV") ?? projects;
46
+ this.started = this.cr.startRun(name, {
47
+ environment,
48
+ project: this.options.project ?? env("PROJECT") ?? path.basename(this.rootDir),
49
+ framework: "playwright",
50
+ });
51
+ this.total = suite.allTests().length;
52
+ }
53
+
54
+ identity(test) {
55
+ const file = path.relative(this.rootDir || process.cwd(), test.location.file).split(path.sep).join("/");
56
+ const titles = test.titlePath().slice(3); // ["", proyecto, archivo, ...describe, título]
57
+ const projectName = test.parent?.project()?.name || "";
58
+ return {
59
+ key: [file, ...titles].join(" > ") + (projectName ? ` [${projectName}]` : ""),
60
+ suite: [file, ...titles.slice(0, -1)].join(" > "),
61
+ params: projectName,
62
+ name: titles.map((t) => t.replace(/\s+@[\w-]+/g, "").trim()).join(" › ") || test.title, // sin los @tags
63
+ category: [...new Set([...(test.tags || []).map((t) => t.replace(/^@/, "")), path.basename(file).replace(/\.(spec|test)\.[cm]?[jt]sx?$/, "")])].join(", "),
64
+ };
65
+ }
66
+
67
+ onTestBegin(test, result) {
68
+ this.chain = this.chain.then(async () => {
69
+ await this.started;
70
+ const entry = this.tests.get(test.id);
71
+ if (entry) { // reintento: mismo test en TraceReports
72
+ const ct = await entry.ct;
73
+ ct.attempts = result.retry + 1;
74
+ ct.warn(`Reintento ${result.retry + 1}: el intento ${result.retry} falló — ${entry.lastError || "falló"}`);
75
+ return;
76
+ }
77
+ const id = this.identity(test);
78
+ const ct = this.cr.startTest(id.name, { key: id.key, suite: id.suite, params: id.params, category: id.category,
79
+ worker: `w${result.workerIndex}` });
80
+ this.tests.set(test.id, { ct });
81
+ }).catch(() => {});
82
+ }
83
+
84
+ // Los pasos se guardan hasta el final del intento: recién ahí se sabe si un error era esperado
85
+ // (test.fail()) y no debe mostrarse como fallo.
86
+ onStepEnd(test, result, step) {
87
+ if (step.category !== "test.step") return;
88
+ const steps = (this.steps.get(test.id) ?? new Map());
89
+ this.steps.set(test.id, steps);
90
+ const attempt = result?.retry ?? 0;
91
+ const list = steps.get(attempt) ?? [];
92
+ steps.set(attempt, list);
93
+ list.push({ title: step.title, error: step.error ? clean(step.error.message) : "", at: +step.startTime || Date.now() });
94
+ }
95
+
96
+ onTestEnd(test, result) {
97
+ this.chain = this.chain.then(async () => {
98
+ const entry = this.tests.get(test.id);
99
+ const ct = await entry?.ct;
100
+ if (!ct) return;
101
+ for (const a of result.annotations ?? test.annotations ?? []) {
102
+ if (a.type === "tracereports-expect") {
103
+ const e = JSON.parse(a.description);
104
+ ct.expectResponse(e.status, { url: e.url, method: e.method });
105
+ }
106
+ }
107
+ // Resultado observado vs. esperado: con test.fail() fallar es lo esperado (Playwright lo da
108
+ // por bueno) y pasar es un error ("Expected to fail, but passed").
109
+ const expected = test.expectedStatus ?? "passed";
110
+ const skipped = result.status === "skipped";
111
+ const asExpected = result.status === expected;
112
+ const unexpected = !asExpected && !skipped;
113
+ const expectedFailure = asExpected && expected === "failed";
114
+
115
+ const error = result.errors?.[0] || result.error;
116
+ const detail = error ? clean(error.message).split("\n").filter(Boolean).slice(0, 3).join(" ") : "";
117
+ const message = unexpected && expected === "failed" && result.status === "passed"
118
+ ? "Se esperaba que fallara (test.fail()), pero pasó"
119
+ : detail || (unexpected ? `Test ${result.status}` : "");
120
+
121
+ // pasos propios (tracereports-log) y test.step, en el orden en que ocurrieron
122
+ const logs = (result.annotations ?? test.annotations ?? []).filter((x) => x.type === "tracereports-log").map((a) => JSON.parse(a.description));
123
+ const steps = (this.steps.get(test.id)?.get(result.retry) ?? []).map((s) => ({
124
+ status: !s.error ? "INFO" : unexpected ? "FAIL" : "WARNING",
125
+ message: s.error ? `${s.title}: ${s.error}${unexpected ? "" : " (error esperado: test.fail())"}` : s.title,
126
+ timestamp: s.at,
127
+ }));
128
+ for (const l of [...steps, ...logs].sort((a, b) => a.timestamp - b.timestamp)) ct.log(l.status, l.message, l.timestamp);
129
+
130
+ for (const att of result.attachments) {
131
+ const body = att.body ?? (att.path && fs.existsSync(att.path) ? fs.readFileSync(att.path) : null);
132
+ if (!body) continue;
133
+ if (att.name === "tracereports-network") ct.network(JSON.parse(body.toString()));
134
+ else if (att.name === "tracereports-dom") ct.dom(JSON.parse(body.toString()));
135
+ else if (att.contentType?.startsWith("image/")) ct.screenshot(body, att.name === "screenshot" ? "Captura al fallar" : att.name,
136
+ unexpected ? "FAIL" : "INFO");
137
+ }
138
+ const final = asExpected || skipped || result.retry >= test.retries;
139
+ if (!final) {
140
+ entry.lastError = message;
141
+ ct.fail(message || `Intento ${result.retry + 1}: ${result.status}`);
142
+ return;
143
+ }
144
+ let status = skipped ? "SKIP" : unexpected ? "FAIL" : "PASS";
145
+ if (status === "FAIL") ct.fail(message);
146
+ if (expectedFailure) ct.info(`Falló como se esperaba (test.fail())${detail ? `: ${detail}` : ""}`);
147
+ if (skipped) {
148
+ const reason = (result.annotations ?? test.annotations ?? []).find((a) => a.type === "skip" || a.type === "fixme")?.description;
149
+ if (reason) ct.skip(reason);
150
+ }
151
+ ct.finish(status, {
152
+ errorMessage: status === "FAIL" ? message : "",
153
+ errorTrace: status === "FAIL" && error ? clean(error.stack || error.message || "") : "",
154
+ attempts: result.retry + 1,
155
+ });
156
+ }).catch(() => {});
157
+ }
158
+
159
+ async onEnd(result) {
160
+ await this.chain;
161
+ await this.started;
162
+ const interrupted = result.status === "interrupted" || result.status === "timedout";
163
+ await this.cr.finishRun({ interrupted });
164
+ const problems = this.cr.deliveryProblems();
165
+ if (this.cr.runId) console.log(`TraceReports: ${this.cr.reportUrl}`);
166
+ if (problems) {
167
+ const d = this.cr.delivery;
168
+ console.warn(`TraceReports: atención: ${problems - d.runNotClosed} eventos de evidencia no llegaron al servidor ` +
169
+ `(enviados ${d.sent}, rechazados ${d.rejected}, descartados ${d.dropped}, perdidos ${d.lost})` +
170
+ (d.runNotClosed ? "; el servidor no confirmó el cierre de la ejecución: quedó abierta" : "") +
171
+ (this.strict ? " — TRACEREPORTS_STRICT: la corrida falla" : ""));
172
+ }
173
+ if (this.strict && (problems || (this.cr.enabled && !this.cr.runId)) && result.status === "passed") {
174
+ return { status: "failed" };
175
+ }
176
+ }
177
+ }
178
+
179
+ function clean(s) {
180
+ return String(s || "").replace(ANSI, "").trim();
181
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }