tracereports 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md CHANGED
@@ -9,4 +9,19 @@ Selenium WebDriver y cualquier runner. Node 18+, sin dependencias.
9
9
  npm install --save-dev tracereports
10
10
  ```
11
11
 
12
+ - **Playwright Test**: el reporter envía cada test con sus pasos, error y capturas, más los traces y
13
+ videos que guarda Playwright; con los fixtures, también la red que vio el navegador, el DOM al
14
+ fallar y la consola del navegador.
15
+ - **Selenium WebDriver o cualquier runner**: con `seleniumOptions(options)` y `withDriver`, la red (pestaña
16
+ Red, Lab) y la consola de cada test, y la captura y el DOM al fallar ([guía de Selenium](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/selenium.md)).
17
+ - **Nunca frena ni rompe tus tests**: la evidencia sale en segundo plano, con reintentos y sin
18
+ duplicados; los shards pueden unirse a la misma ejecución.
19
+ - **Sin servidor**: si el servidor está caído o rechaza el token, la ejecución se graba en local;
20
+ `tracereports report <carpeta>` arma el reporte HTML y `tracereports push <carpeta>` lo sube después.
21
+ Con `TRACEREPORTS_OFFLINE=both` además guarda esa copia local mientras envía al servidor
22
+ ([guía del reporte local](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/local-report.md)).
23
+ - **Configuración** desde el entorno o el `.env` del proyecto (`TRACEREPORTS_URL`, `TRACEREPORTS_TOKEN`…).
24
+
12
25
  Documentación completa: [docs/es/javascript.md](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/javascript.md).
26
+
27
+ Sin binario instalado, al cerrar se descarga y verifica el renderer v0.2.0 en la caché del usuario; `TRACEREPORTS_BIN_DOWNLOAD=0` permite trabajar sin internet con un binario instalado o en caché. Ver [modo offline](../../docs/es/offline.md).
package/README.md CHANGED
@@ -9,4 +9,19 @@ Selenium WebDriver and any runner. Node 18+, no dependencies.
9
9
  npm install --save-dev tracereports
10
10
  ```
11
11
 
12
+ - **Playwright Test**: the reporter sends each test with its steps, error and screenshots, plus the
13
+ traces and videos Playwright keeps; with the fixtures, also the network the browser saw, the DOM
14
+ at the failure and the browser console.
15
+ - **Selenium WebDriver or any runner**: with `seleniumOptions(options)` and `withDriver`, each test's network
16
+ (Network tab, Lab) and console, and the screenshot and DOM on failure ([Selenium guide](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/selenium.md)).
17
+ - **Never slows down or breaks your tests**: evidence goes out in the background with retries and
18
+ no duplicates; shards can join the same run.
19
+ - **Without a server**: if the server is down or rejects the token, the run is recorded locally;
20
+ `tracereports report <folder>` builds the HTML report and `tracereports push <folder>` uploads it later.
21
+ With `TRACEREPORTS_OFFLINE=both` it also keeps that local copy while sending to the server
22
+ ([local report guide](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/local-report.md)).
23
+ - **Configuration** from the environment or the project's `.env` (`TRACEREPORTS_URL`, `TRACEREPORTS_TOKEN`…).
24
+
12
25
  Full documentation: [docs/en/javascript.md](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/javascript.md).
26
+
27
+ Without an installed binary, closing downloads and verifies the v0.2.0 renderer in the user cache; `TRACEREPORTS_BIN_DOWNLOAD=0` supports offline use with an installed or cached binary. See [offline mode](../../docs/en/offline.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracereports",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "TraceReports client for JavaScript/TypeScript: Playwright Test reporter and fixtures, Selenium WebDriver helpers and a client for any runner.",
5
5
  "keywords": [
6
6
  "testing",
@@ -43,7 +43,8 @@
43
43
  "node": ">=18"
44
44
  },
45
45
  "scripts": {
46
- "test": "node --test \"test/*.test.js\""
46
+ "test": "node --test \"test/*.test.js\"",
47
+ "typecheck": "npx --yes -p typescript@7.0.2 tsc --noEmit --strict --skipLibCheck --target es2022 --module nodenext --moduleResolution nodenext test/types/public-api.ts"
47
48
  },
48
49
  "peerDependencies": {
49
50
  "@playwright/test": ">=1.40"
package/src/binary.js ADDED
@@ -0,0 +1,125 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { gunzipSync, inflateRawSync } from "node:zlib";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { env } from "./env.js";
8
+
9
+ export const REPORT_VERSION = "0.2.0";
10
+ const RELEASES = "https://github.com/josemiguellopez/tracereports/releases/download";
11
+ const MAX_BINARY = 256 * 1024 * 1024;
12
+ const STALE_LOCK_MS = 300_000;
13
+ const sha = data => createHash("sha256").update(data).digest("hex");
14
+
15
+ // Read only the exact executable member; archive paths are never used as output paths.
16
+ function executable(archive, name, windows) {
17
+ let found;
18
+ const accept = data => { if (found || !data.length || data.length > MAX_BINARY) throw Error("invalid release executable"); found = data; };
19
+ if (windows) {
20
+ let end = archive.length - 22;
21
+ while (end >= Math.max(0, archive.length - 65557) && archive.readUInt32LE(end) !== 0x06054b50) end--;
22
+ if (end < 0 || archive.readUInt32LE(end) !== 0x06054b50) throw Error("invalid ZIP");
23
+ let offset = archive.readUInt32LE(end + 16);
24
+ for (let i = 0; i < archive.readUInt16LE(end + 10); i++) {
25
+ if (archive.readUInt32LE(offset) !== 0x02014b50) throw Error("invalid ZIP directory");
26
+ const length = archive.readUInt16LE(offset + 28);
27
+ if (archive.toString("utf8", offset + 46, offset + 46 + length) === name) {
28
+ const method = archive.readUInt16LE(offset + 10), size = archive.readUInt32LE(offset + 20);
29
+ const local = archive.readUInt32LE(offset + 42);
30
+ if (archive.readUInt32LE(local) !== 0x04034b50 || archive.readUInt32LE(offset + 24) > MAX_BINARY) throw Error("invalid ZIP executable");
31
+ const start = local + 30 + archive.readUInt16LE(local + 26) + archive.readUInt16LE(local + 28);
32
+ if (start + size > archive.length) throw Error("truncated ZIP");
33
+ const compressed = archive.subarray(start, start + size);
34
+ if (![0, 8].includes(method)) throw Error("unsupported ZIP compression");
35
+ accept(method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: MAX_BINARY }));
36
+ }
37
+ offset += 46 + length + archive.readUInt16LE(offset + 30) + archive.readUInt16LE(offset + 32);
38
+ }
39
+ } else {
40
+ const tar = gunzipSync(archive, { maxOutputLength: MAX_BINARY + 8 * 1024 * 1024 });
41
+ for (let offset = 0; offset + 512 <= tar.length; ) {
42
+ const header = tar.subarray(offset, offset + 512);
43
+ if (header.every(b => b === 0)) break;
44
+ const member = header.toString("utf8", 0, 100).split("\0")[0];
45
+ const size = parseInt(header.toString("ascii", 124, 136).replace(/\0/g, "").trim(), 8);
46
+ if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) throw Error("invalid TAR member");
47
+ if (member === name && [0, 48].includes(header[156]) && header[345] === 0) accept(tar.subarray(offset + 512, offset + 512 + size));
48
+ offset += 512 + Math.ceil(size / 512) * 512;
49
+ }
50
+ }
51
+ if (!found) throw Error("release executable missing");
52
+ return found;
53
+ }
54
+
55
+ export async function downloadBinary(timeoutMs = 60_000) {
56
+ const system = { win32: "windows", linux: "linux", darwin: "darwin" }[process.platform];
57
+ const arch = { x64: "amd64", arm64: "arm64" }[process.arch];
58
+ if (!system || !arch) throw Error("unsupported report platform");
59
+ const base = (system === "windows" ? process.env.LOCALAPPDATA : system === "darwin" ? "" : process.env.XDG_CACHE_HOME) ||
60
+ (system === "darwin" ? path.join(os.homedir(), "Library", "Caches") : path.join(os.homedir(), ".cache"));
61
+ const parent = path.join(base, "tracereports", REPORT_VERSION), target = path.join(parent, `${system}_${arch}`);
62
+ const name = system === "windows" ? "tracereports.exe" : "tracereports", binary = path.join(target, name);
63
+ const cached = () => {
64
+ try { return fs.lstatSync(binary).isFile() && sha(fs.readFileSync(binary)) === fs.readFileSync(path.join(target, "binary.sha256"), "utf8").trim(); }
65
+ catch { return false; }
66
+ };
67
+ if (cached()) return binary;
68
+ if (env("BIN_DOWNLOAD", "1") === "0") throw Error("binary download disabled");
69
+ const deadline = Date.now() + timeoutMs, lock = path.join(parent, `${system}_${arch}.lock`);
70
+ fs.mkdirSync(parent, { recursive: true });
71
+ while (true) {
72
+ if (cached()) return binary;
73
+ if (Date.now() >= deadline) throw Error("waiting for report binary cache timed out");
74
+ try { fs.mkdirSync(lock); break; }
75
+ catch (err) {
76
+ if (err.code !== "EEXIST") throw err;
77
+ // A download holds the lock for at most its timeout: an older one was left by a process that
78
+ // died mid-download and would otherwise block every later run.
79
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > STALE_LOCK_MS) { fs.rmdirSync(lock); continue; } } catch { /* removed meanwhile */ }
80
+ await sleep(50);
81
+ }
82
+ }
83
+ let staging;
84
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), Math.max(1, deadline - Date.now()));
85
+ try {
86
+ if (cached()) return binary;
87
+ const baseUrl = env("BIN_BASE_URL") || RELEASES;
88
+ if (baseUrl !== RELEASES) {
89
+ const url = new URL(baseUrl);
90
+ if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || url.username || url.password) throw Error("BIN_BASE_URL must be a loopback test server");
91
+ }
92
+ const asset = `tracereports_${REPORT_VERSION}_${system}_${arch}.${system === "windows" ? "zip" : "tar.gz"}`;
93
+ const download = async (filename, limit) => {
94
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}/v${REPORT_VERSION}/${filename}`, { signal: controller.signal });
95
+ if (!response.ok) { await response.body?.cancel(); throw Error(`release download HTTP ${response.status}`); }
96
+ const chunks = []; let size = 0;
97
+ for await (const chunk of response.body) {
98
+ size += chunk.length;
99
+ if (size > limit) throw Error("release download too large");
100
+ chunks.push(chunk);
101
+ }
102
+ return Buffer.concat(chunks);
103
+ };
104
+ const checksums = (await download("checksums.txt", 1024 * 1024)).toString("utf8");
105
+ const hashes = checksums.split(/\r?\n/).map(line => line.trim().split(/\s+/)).filter(p => p.length === 2 && p[1].replace(/^\*/, "") === asset).map(p => p[0].toLowerCase());
106
+ if (hashes.length !== 1 || !/^[a-f0-9]{64}$/.test(hashes[0])) throw Error("release checksum missing or ambiguous");
107
+ const archive = await download(asset, 128 * 1024 * 1024);
108
+ if (sha(archive) !== hashes[0]) throw Error("release checksum mismatch");
109
+ const data = executable(archive, name, system === "windows");
110
+ fs.mkdirSync(target, { recursive: true });
111
+ staging = fs.mkdtempSync(path.join(parent, ".download-"));
112
+ fs.writeFileSync(path.join(staging, name), data, { mode: 0o755 });
113
+ fs.writeFileSync(path.join(staging, "binary.sha256"), sha(data));
114
+ // Other processes wait on the lock while the two cache files are replaced.
115
+ fs.rmSync(binary, { force: true });
116
+ fs.rmSync(path.join(target, "binary.sha256"), { force: true });
117
+ fs.renameSync(path.join(staging, "binary.sha256"), path.join(target, "binary.sha256"));
118
+ fs.renameSync(path.join(staging, name), binary);
119
+ return binary;
120
+ } finally {
121
+ clearTimeout(timer);
122
+ if (staging) fs.rmSync(staging, { recursive: true, force: true });
123
+ fs.rmdirSync(lock);
124
+ }
125
+ }
package/src/env.js CHANGED
@@ -86,10 +86,13 @@ export function resetEnvFile() {
86
86
  filePath = null;
87
87
  }
88
88
 
89
- /** TRACEREPORTS_<name>: primero del entorno, después del .env del proyecto. */
89
+ /**
90
+ * TRACEREPORTS_<name>: del entorno y, si no está definida ahí, del .env del proyecto. Definida en el
91
+ * entorno manda aunque esté vacía (como en el servidor): vacía es "sin configurar" (fallback) y el
92
+ * .env no la reemplaza.
93
+ */
90
94
  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
+ const own = process.env[PREFIX + name];
96
+ const v = own !== undefined ? own : fromFile()[PREFIX + name];
97
+ return v !== undefined && v !== "" ? v : fallback;
95
98
  }
package/src/index.d.ts CHANGED
@@ -4,11 +4,21 @@ export interface Delivery {
4
4
  sent: number; retried: number; rejected: number; dropped: number; lost: number; pending: number; unregisteredTests: number;
5
5
  /** 1 si el servidor no confirmó el cierre de la ejecución. */
6
6
  runNotClosed: number;
7
+ /** Events recorded locally, including the copy in both mode. */
8
+ recorded?: number;
7
9
  }
8
10
 
9
11
  export interface TraceReportsOptions {
10
12
  baseUrl?: string; token?: string; enabled?: boolean; timeoutMs?: number; uploadTimeoutMs?: number;
11
13
  flushTimeoutMs?: number; maxQueueItems?: number; maxQueueMB?: number;
14
+ /** Exact recording folder, shared by the processes of one run (default $TRACEREPORTS_OFFLINE_DIR; without it, a new folder per session inside offlineBase). */
15
+ offlineDir?: string;
16
+ /** Folder where each session creates its own recording folder (default $TRACEREPORTS_OFFLINE_BASE or ./tracereports-offline). */
17
+ offlineBase?: string;
18
+ /** Label for automatically created recording folders (default: the run name). */
19
+ offlineName?: string;
20
+ /** auto (default): record if run creation fails; always: no server; both: send and record; off: never record. */
21
+ offline?: "auto" | "always" | "both" | "off";
12
22
  }
13
23
 
14
24
  export interface RunOptions { environment?: string; project?: string; branch?: string; commit?: string; framework?: string }
@@ -16,6 +26,10 @@ export interface RunOptions { environment?: string; project?: string; branch?: s
16
26
  export interface TestOptions {
17
27
  category?: string; description?: string; key?: string; suite?: string; params?: string; worker?: string | number;
18
28
  status?: string; onFailure?: (t: TraceTest, err: unknown) => unknown | Promise<unknown>;
29
+ /** Right after the test starts (e.g. discard the browser logs of earlier tests). */
30
+ onStart?: (t: TraceTest) => unknown | Promise<unknown>;
31
+ /** Before the test is closed, passed or failed (`err` is null when it passed): network, console... */
32
+ onEnd?: (t: TraceTest, err: unknown) => unknown | Promise<unknown>;
19
33
  }
20
34
 
21
35
  export type Status = "INFO" | "PASS" | "FAIL" | "WARNING" | "SKIP";
@@ -25,6 +39,8 @@ export interface Connection {
25
39
  failed?: boolean; error_text?: string; started_at?: number; duration_ms?: number | null;
26
40
  request_headers?: Record<string, string>; post_data?: string; response_headers?: Record<string, string>;
27
41
  response_body?: string; body_size?: number; expected?: boolean;
42
+ /** The body was already cut before reaching the client (body_size keeps the original size). */
43
+ body_truncated?: boolean;
28
44
  }
29
45
 
30
46
  export class TraceReports {
@@ -32,9 +48,13 @@ export class TraceReports {
32
48
  baseUrl: string; runId: number | null; runCreated: boolean; enabled: boolean;
33
49
  readonly delivery: Delivery;
34
50
  readonly reportUrl: string;
51
+ /** True when evidence is recorded locally, including both mode. */
52
+ readonly recording: boolean;
53
+ offlineDir: string | null;
54
+ offlineReport: string | null;
35
55
  deliveryProblems(): number;
36
56
  startRun(name: string, opts?: RunOptions): Promise<number | null>;
37
- joinRun(runId: number | string): number | null;
57
+ joinRun(runId: number | string, opts?: { offlineDir?: string }): number | null;
38
58
  finishRun(opts?: { interrupted?: boolean }): Promise<unknown>;
39
59
  flush(timeoutMs?: number): Promise<number>;
40
60
  startTest(name: string, opts?: TestOptions): Promise<TraceTest>;
@@ -42,7 +62,13 @@ export class TraceReports {
42
62
  test<T>(name: string, opts: TestOptions, fn: (t: TraceTest) => T | Promise<T>): Promise<T>;
43
63
  }
44
64
 
65
+ export interface ConsoleEntry { level: "error" | "warning" | "pageerror" | "info" | "log" | "debug"; text: string; location?: string; timestamp?: number }
66
+
45
67
  export class TraceTest {
68
+ /** Browser console of the test (its "Console" tab). Captured by the tracereports/playwright fixtures. */
69
+ console(entries: ConsoleEntry[]): boolean;
70
+ /** Attaches the Playwright trace (trace.zip) or the test video (WebM/MP4): a Buffer or a file path. */
71
+ artifact(kind: "trace" | "video", data: Buffer | Uint8Array | string, name?: string): boolean;
46
72
  readonly id: number | null; readonly active: boolean; attempts: number;
47
73
  log(status: Status, message: string, timestamp?: number): void;
48
74
  info(message: string): void; pass(message: string): void; fail(message: string): void; warn(message: string): void; skip(message: string): void;
@@ -53,6 +79,10 @@ export class TraceTest {
53
79
  finish(status?: Status | "", opts?: { errorMessage?: string; errorTrace?: string; error?: unknown; attempts?: number }): void;
54
80
  }
55
81
 
82
+ /** Lotes de red (bytes del JSON enviado): el servidor acepta hasta 48 MiB por request. */
83
+ export const NETWORK_BATCH_BYTES: number;
84
+ export const NETWORK_ALONE_BYTES: number;
85
+ export function networkBatches(payload: object[], opts?: { maxCount?: number; maxBytes?: number; aloneBytes?: number }): string[];
56
86
  export const DOM_SCRIPT: string;
57
87
 
58
88
  export function domSnapshotInPage(): unknown;
package/src/index.js CHANGED
@@ -12,18 +12,71 @@
12
12
  // await cr.finishRun(); // espera la cola y cierra la ejecución
13
13
  //
14
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ó.
15
+ // Idempotency-Key (sin duplicados); `cr.delivery` dice qué no llegó. Si la ejecución no se puede
16
+ // crear (sin servidor, caído o con el token equivocado) la evidencia se graba en una carpeta
17
+ // ($TRACEREPORTS_OFFLINE_DIR, default ./tracereports-offline/<sesión>) en vez de perderse:
18
+ // `tracereports report <carpeta>` arma el reporte HTML y `tracereports push <carpeta>` la sube.
17
19
 
20
+ import { spawnSync } from "node:child_process";
21
+ import fs from "node:fs";
18
22
  import path from "node:path";
19
23
  import { detectBranch, detectCommit } from "./context.js";
20
24
  import { env } from "./env.js";
25
+ import { Recorder, newSessionDir } from "./offline.js";
26
+ import { Mirror } from "./mirror.js";
21
27
  import { Sender } from "./transport.js";
28
+ import { downloadBinary } from "./binary.js";
22
29
 
23
- export const VERSION = "0.1.0";
30
+ export const VERSION = "0.3.0";
24
31
  const STATUSES = new Set(["INFO", "PASS", "FAIL", "WARNING", "SKIP"]);
25
32
  const truthy = (v) => ["1", "true", "yes"].includes(String(v || "").toLowerCase());
26
33
 
34
+ // Lotes de red: el servidor acepta hasta 48 MiB por request. Se arma cada lote por los bytes del
35
+ // JSON que realmente se envía (UTF-8, con escapes), hasta NETWORK_BATCH_BYTES y batchSize
36
+ // conexiones. Una conexión más grande va sola; si sola pasa NETWORK_ALONE_BYTES, se envía sin
37
+ // bodies ni headers (body_truncated, con su body_size original) para que siempre entre.
38
+ export const NETWORK_BATCH_BYTES = 8 << 20;
39
+ export const NETWORK_ALONE_BYTES = 40 << 20;
40
+
41
+ const cut = (s, n) => {
42
+ const str = String(s ?? "");
43
+ if (str.length <= n) return str;
44
+ const end = /[\uD800-\uDBFF]/.test(str[n - 1]) ? n - 1 : n; // sin partir un par sustituto
45
+ return str.slice(0, end);
46
+ };
47
+
48
+ function slimConnection(c) {
49
+ return {
50
+ ...c, url: cut(c.url, 4096), status_text: cut(c.status_text, 256), mime_type: cut(c.mime_type, 256),
51
+ resource_type: cut(c.resource_type, 32), error_text: cut(c.error_text, 2048), method: cut(c.method, 16),
52
+ request_headers: {}, response_headers: {}, post_data: "", response_body: "", body_truncated: true,
53
+ };
54
+ }
55
+
56
+ /** Lotes de JSON {"connections":[...]} de a lo sumo maxBytes y maxCount, en orden. */
57
+ export function networkBatches(payload, { maxCount = 200, maxBytes = NETWORK_BATCH_BYTES, aloneBytes = NETWORK_ALONE_BYTES } = {}) {
58
+ const head = '{"connections":[', tail = "]}";
59
+ const out = [];
60
+ let parts = [], size = 0;
61
+ for (let c of payload) {
62
+ let raw = JSON.stringify(c), n = Buffer.byteLength(raw);
63
+ if (n + head.length + tail.length > aloneBytes) {
64
+ raw = JSON.stringify(slimConnection(c));
65
+ n = Buffer.byteLength(raw);
66
+ }
67
+ if (parts.length && (parts.length >= maxCount || size + 1 + n > maxBytes)) {
68
+ out.push(head + parts.join(",") + tail);
69
+ parts = [];
70
+ size = head.length + tail.length;
71
+ }
72
+ if (!parts.length) size = head.length + tail.length;
73
+ size += (parts.length ? 1 : 0) + n;
74
+ parts.push(raw);
75
+ }
76
+ if (parts.length) out.push(head + parts.join(",") + tail);
77
+ return out;
78
+ }
79
+
27
80
  export class TraceReports {
28
81
  /**
29
82
  * @param {object} [opts]
@@ -35,6 +88,10 @@ export class TraceReports {
35
88
  * @param {number} [opts.flushTimeoutMs] cuánto espera finishRun la cola (default $TRACEREPORTS_FLUSH_TIMEOUT s o 30 s)
36
89
  * @param {number} [opts.maxQueueItems] default 5000
37
90
  * @param {number} [opts.maxQueueMB] default 64
91
+ * @param {string} [opts.offlineDir] dónde grabar sin servidor (default $TRACEREPORTS_OFFLINE_DIR; sin
92
+ * ella, una carpeta nueva por sesión dentro de ./tracereports-offline)
93
+ * @param {"auto"|"always"|"both"|"off"} [opts.offline] auto (default, $TRACEREPORTS_OFFLINE): grabar solo si
94
+ * la ejecución no se puede crear; always: grabar sin intentar un servidor; off: nunca
38
95
  */
39
96
  constructor(opts = {}) {
40
97
  this.baseUrl = (opts.baseUrl || env("URL") || "http://localhost:8080").replace(/\/+$/, "");
@@ -51,6 +108,68 @@ export class TraceReports {
51
108
  this.runCreated = false;
52
109
  this.runNotClosed = false;
53
110
  this.unregisteredTests = 0;
111
+
112
+ let mode = String(opts.offline ?? env("OFFLINE", "auto")).trim().toLowerCase();
113
+ if (["1", "true", "yes", "on"].includes(mode)) mode = "always";
114
+ if (["0", "false", "no"].includes(mode)) mode = "off";
115
+ this.offlineMode = mode;
116
+ const explicit = opts.offlineDir || env("OFFLINE_DIR") || "";
117
+ // sin carpeta exacta, cada sesión crea la suya dentro de la base: corridas sucesivas no se pisan
118
+ this.offlineBase = explicit || opts.offlineBase || env("OFFLINE_BASE") || "tracereports-offline";
119
+ this.offlineExplicit = Boolean(explicit);
120
+ this.offlineName = opts.offlineName || env("OFFLINE_NAME", "");
121
+ this.offlineDir = null; // carpeta donde se graba (null: se envía al servidor)
122
+ this.offlineReport = null; // index.html generado al cerrar
123
+ }
124
+
125
+ /** true si la evidencia se graba localmente (sin servidor). */
126
+ get recording() {
127
+ return this.offlineDir != null;
128
+ }
129
+
130
+ goOffline(reason = "") {
131
+ const dir = this.offlineExplicit ? this.offlineBase : newSessionDir(this.offlineBase, this.offlineName);
132
+ try {
133
+ this.sender = new Recorder(dir);
134
+ } catch (err) {
135
+ console.warn(`tracereports: no se pudo grabar en ${dir}: ${err.message}`);
136
+ return;
137
+ }
138
+ this.offlineDir = dir;
139
+ if (reason) {
140
+ console.warn(`tracereports: ${reason}; la evidencia se graba en ${dir}`);
141
+ }
142
+ }
143
+
144
+ goMirror(runId, payload, directory) {
145
+ const dir = directory || (this.offlineExplicit ? this.offlineBase : newSessionDir(this.offlineBase, this.offlineName));
146
+ try {
147
+ this.sender = new Mirror(this.sender, dir, this.baseUrl, runId, payload);
148
+ this.offlineDir = dir;
149
+ } catch (err) { console.warn(`tracereports: no se pudo iniciar la copia local en ${dir}: ${err.message}`); }
150
+ }
151
+
152
+ /** Arma el HTML con el binario instalado o el renderer verificado de la caché. */
153
+ async buildOfflineReport({ zip } = {}) {
154
+ try {
155
+ if (["0", "false", "no"].includes(String(env("OFFLINE_REPORT", "1")).toLowerCase())) throw Error("automatic report disabled");
156
+ const bin = env("BIN") || findInPath("tracereports") || await downloadBinary();
157
+ const out = path.join(this.offlineDir, "report");
158
+ const args = ["report", "-o", out, ...(zip ? ["--zip", zip] : []), this.offlineDir];
159
+ const res = spawnSync(bin, args, { timeout: 300_000, encoding: "utf8" });
160
+ if (res.status !== 0) throw Error((res.stderr || res.error?.message || "renderer failed").trim());
161
+ const index = path.join(out, "index.html");
162
+ if (!fs.statSync(index).isFile()) throw Error("renderer did not create index.html");
163
+ this.offlineReport = index;
164
+ if (this.runId > 0) console.warn(`tracereports: server report: ${this.reportUrl}`);
165
+ console.warn(`tracereports: reporte local: ${this.offlineReport}`);
166
+ return this.offlineReport;
167
+ } catch (err) {
168
+ console.warn(`tracereports: local report unavailable (${err.message}); evidence retained in ${this.offlineDir}. ` +
169
+ `install the binary and run \`tracereports report ${this.offlineDir}\`, or run ` +
170
+ `\`tracereports push ${this.offlineDir}\` with the correct token when the server is available.`);
171
+ return null;
172
+ }
54
173
  }
55
174
 
56
175
  headers() {
@@ -76,6 +195,7 @@ export class TraceReports {
76
195
  }
77
196
 
78
197
  get reportUrl() {
198
+ if (this.recording && this.runId < 0) return this.offlineReport || "";
79
199
  return this.runId ? `${this.baseUrl}/#run=${this.runId}&view=dashboard` : "";
80
200
  }
81
201
 
@@ -98,16 +218,26 @@ export class TraceReports {
98
218
  * ambiente y rama.
99
219
  */
100
220
  async startRun(name, { environment = "", project, branch, commit, framework = "" } = {}) {
221
+ this.offlineName = name || this.offlineName;
101
222
  if (env("RUN_ID")) return this.joinRun(env("RUN_ID"));
102
- const res = await this.request("POST", "/api/v1/runs", {
223
+ const payload = {
103
224
  name,
104
225
  environment,
105
226
  project: project ?? env("PROJECT") ?? path.basename(process.cwd()),
106
227
  branch: branch ?? detectBranch(),
107
228
  commit: commit ?? detectCommit(),
108
229
  framework,
109
- });
110
- this.runId = res?.run_id ?? null;
230
+ };
231
+ if (this.enabled && this.offlineMode === "always" && !this.recording) this.goOffline("offline mode");
232
+ const res = await this.request("POST", "/api/v1/runs", payload);
233
+ let created = res;
234
+ if (!created && this.enabled && ["auto", "both"].includes(this.offlineMode) && !this.recording) {
235
+ // sin servidor, caído o con el token equivocado: se graba en vez de perderlo todo
236
+ this.goOffline(`no se pudo crear la ejecución en ${this.baseUrl} (servidor caído o token incorrecto)`);
237
+ if (this.recording) created = await this.request("POST", "/api/v1/runs", payload);
238
+ }
239
+ if (created?.run_id > 0 && this.offlineMode === "both" && !this.recording) this.goMirror(created.run_id, payload);
240
+ this.runId = created?.run_id ?? null;
111
241
  this.runCreated = Boolean(this.runId);
112
242
  if (this.enabled && !this.runId) {
113
243
  console.warn(`tracereports: no se pudo crear la ejecución en ${this.baseUrl}; los tests siguen sin reporte.`);
@@ -115,8 +245,19 @@ export class TraceReports {
115
245
  return this.runId;
116
246
  }
117
247
 
118
- /** Reporta en una ejecución creada en otro proceso; quien la creó la cierra. */
119
- joinRun(runId) {
248
+ /**
249
+ * Reporta en una ejecución creada en otro proceso; quien la creó la cierra. Un runId negativo es
250
+ * una ejecución que se graba sin servidor: pasa la misma `offlineDir`.
251
+ */
252
+ joinRun(runId, { offlineDir } = {}) {
253
+ if (runId && Number(runId) < 0 && this.enabled && !this.recording) {
254
+ if (offlineDir || env("OFFLINE_DIR")) {
255
+ this.offlineBase = offlineDir || env("OFFLINE_DIR");
256
+ this.offlineExplicit = true;
257
+ }
258
+ this.goOffline();
259
+ }
260
+ if (Number(runId) > 0 && this.enabled && this.offlineMode === "both" && !this.recording) this.goMirror(Number(runId), null, offlineDir);
120
261
  this.runId = runId ? Number(runId) : null;
121
262
  this.runCreated = false;
122
263
  return this.runId;
@@ -124,13 +265,26 @@ export class TraceReports {
124
265
 
125
266
  /** Espera la cola (flushTimeoutMs) y cierra la ejecución. interrupted: incompleta, nunca verde. */
126
267
  async finishRun({ interrupted = false } = {}) {
127
- await this.flush();
268
+ if (this.sender instanceof Mirror && this.sender.closed) return null;
269
+ const pending = await this.flush();
128
270
  this.sender.abandon();
271
+ if (!this.runCreated && this.sender instanceof Mirror) this.sender.close(pending === 0 && this.deliveryProblems() === 0 && !interrupted);
129
272
  if (!this.runId || !this.runCreated || !this.enabled) return null; // unido a otra: la cierra su dueño
130
273
  // el cierre importa más que un paso: más reintentos, siempre con la misma Idempotency-Key
131
274
  const res = await this.sender.sendNow("PATCH", `/api/v1/runs/${this.runId}/finish`,
132
275
  JSON.stringify({ interrupted }), "application/json", this.timeoutMs, 4, { ignoreCircuit: true });
133
276
  this.runNotClosed = res == null;
277
+ if (this.recording) {
278
+ if (this.sender instanceof Mirror) {
279
+ this.sender.close(pending === 0 && this.deliveryProblems() === 0 && !interrupted);
280
+ const before = this.sender.prepare();
281
+ await this.buildOfflineReport();
282
+ this.sender.finalize(this.offlineReport, env("OFFLINE_KEEP") === "1", before);
283
+ return res;
284
+ }
285
+ await this.buildOfflineReport();
286
+ return res;
287
+ }
134
288
  if (this.runNotClosed) {
135
289
  console.warn(`tracereports: el servidor no confirmó el cierre de la ejecución #${this.runId}: quedó abierta ` +
136
290
  `(ciérrala con PATCH ${this.baseUrl}/api/v1/runs/${this.runId}/finish)`);
@@ -154,25 +308,29 @@ export class TraceReports {
154
308
  if (!this.runId) return new TraceTest(this, null);
155
309
  const res = await this.request("POST", `/api/v1/runs/${this.runId}/tests`,
156
310
  { name, category, description, key, suite, params, worker: String(worker ?? "") });
157
- if (this.enabled && !res?.test_id) this.unregisteredTests++;
311
+ if (this.enabled && (!res?.test_id || (this.sender instanceof Mirror && res.test_id < 0))) this.unregisteredTests++;
158
312
  return new TraceTest(this, res?.test_id ?? null);
159
313
  }
160
314
 
161
315
  /**
162
316
  * 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.
317
+ * captura y el snapshot de `onFailure` (opcional). `onStart` corre al empezar y `onEnd` antes de
318
+ * cerrarlo, pase o falle (la red y la consola de `withDriver`). El error se vuelve a lanzar.
164
319
  */
165
320
  async test(name, opts, fn) {
166
321
  if (typeof opts === "function") [fn, opts] = [opts, {}];
167
322
  const t = await this.startTest(name, opts);
323
+ // la evidencia nunca rompe el test
324
+ const hook = async (h, ...args) => { if (h) { try { await h(t, ...args); } catch { /* sin evidencia */ } } };
325
+ await hook(opts.onStart);
168
326
  try {
169
327
  const out = await fn(t);
328
+ await hook(opts.onEnd, null);
170
329
  t.finish(opts.status);
171
330
  return out;
172
331
  } catch (err) {
173
- if (opts.onFailure) {
174
- try { await opts.onFailure(t, err); } catch { /* la evidencia nunca rompe el test */ }
175
- }
332
+ await hook(opts.onFailure, err);
333
+ await hook(opts.onEnd, err);
176
334
  t.fail(`${err?.name || "Error"}: ${err?.message ?? err}`);
177
335
  t.finish("FAIL", { error: err });
178
336
  throw err;
@@ -224,6 +382,47 @@ export class TraceTest {
224
382
  this.cr.emit("POST", `/api/v1/tests/${this.id}/screenshot`, body, `multipart/form-data; boundary=${boundary}`, this.cr.uploadTimeoutMs);
225
383
  }
226
384
 
385
+ /**
386
+ * Consola del navegador del test (pestaña "Consola"): [{level: "error"|"warning"|"pageerror"|...,
387
+ * text, location, timestamp}]. Con los fixtures de `tracereports/playwright` se captura sola.
388
+ */
389
+ console(entries) {
390
+ if (!this.active || !entries?.length) return false;
391
+ return this.cr.emit("POST", `/api/v1/tests/${this.id}/console`, JSON.stringify({ entries: entries.slice(0, 500) }), "application/json",
392
+ Math.max(this.cr.uploadTimeoutMs, 10_000));
393
+ }
394
+
395
+ /**
396
+ * Adjunta el trace de Playwright (`kind: "trace"`, el trace.zip) o el video del test
397
+ * (`kind: "video"`, WebM o MP4): Buffer o ruta. Hasta 100 MB; el reporte muestra el video y abre el
398
+ * trace en el Trace Viewer.
399
+ */
400
+ artifact(kind, data, name = "") {
401
+ if (!this.active || !data) return false;
402
+ let body = data;
403
+ if (typeof data === "string") {
404
+ try {
405
+ body = fs.readFileSync(data);
406
+ name ||= path.basename(data);
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+ if (body.length > 100 << 20) {
412
+ console.warn(`tracereports: ${name || kind} pesa más de 100 MB: no se adjunta`);
413
+ return false;
414
+ }
415
+ const boundary = `----tracereports${Math.random().toString(16).slice(2)}`;
416
+ const part = (n, v) => `--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`;
417
+ const multipart = Buffer.concat([
418
+ Buffer.from(part("kind", kind) + part("name", String(name).replace(/[\r\n"]/g, " "))),
419
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="artifact"\r\nContent-Type: application/octet-stream\r\n\r\n`),
420
+ Buffer.from(body),
421
+ Buffer.from(`\r\n--${boundary}--\r\n`),
422
+ ]);
423
+ return this.cr.emit("POST", `/api/v1/tests/${this.id}/artifact`, multipart, `multipart/form-data; boundary=${boundary}`, Math.max(this.cr.uploadTimeoutMs, 60_000));
424
+ }
425
+
227
426
  /**
228
427
  * Declara una respuesta negativa que el test verifica a propósito (p. ej. un 401 con
229
428
  * credenciales inválidas): no cuenta como error ni como causa del fallo. `url`: texto contenido
@@ -256,14 +455,14 @@ export class TraceTest {
256
455
  error_text: String(c.error_text || ""), started_at: Math.round(c.started_at || 0),
257
456
  duration_ms: c.duration_ms == null ? null : Math.round(c.duration_ms),
258
457
  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,
458
+ // el body pudo llegar ya recortado (captura): su indicador y su tamaño original (bytes) se conservan
459
+ response_headers: c.response_headers || {}, response_body: cut(body, limit),
460
+ body_size: Math.max(Number(c.body_size || 0), Buffer.byteLength(body)), body_truncated: Boolean(c.body_truncated) || body.length > limit,
261
461
  expected: Boolean(c.expected) || this.isExpected(c),
262
462
  };
263
463
  });
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);
464
+ for (const body of networkBatches(payload, { maxCount: batchSize })) {
465
+ this.cr.emit("POST", `/api/v1/tests/${this.id}/network`, body, "application/json", this.cr.uploadTimeoutMs);
267
466
  }
268
467
  return { stored: payload.length, errors: payload.filter((p) => !p.expected && (p.failed || p.status >= 400)).length };
269
468
  }
@@ -294,3 +493,17 @@ export class TraceTest {
294
493
 
295
494
  export { DOM_SCRIPT, domSnapshotInPage } from "./dom.js";
296
495
 
496
+ function findInPath(name) {
497
+ const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE").split(";") : [""];
498
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
499
+ for (const ext of exts) {
500
+ const file = path.join(dir, name + ext.toLowerCase());
501
+ try {
502
+ if (dir && fs.statSync(file).isFile()) return file;
503
+ } catch {
504
+ // no está aquí
505
+ }
506
+ }
507
+ }
508
+ return null;
509
+ }