tracereports 0.1.2 → 0.2.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 +14 -0
- package/README.md +14 -0
- package/package.json +3 -2
- package/src/binary.js +125 -0
- package/src/env.js +8 -5
- package/src/index.d.ts +27 -1
- package/src/index.js +223 -14
- package/src/mirror.js +173 -0
- package/src/offline.js +132 -0
- package/src/playwright.js +39 -1
- package/src/reporter.js +20 -5
- package/src/transport.js +31 -3
package/README.es.md
CHANGED
|
@@ -9,4 +9,18 @@ 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**: captura y DOM al fallar, y una API para pasos y red.
|
|
16
|
+
- **Nunca frena ni rompe tus tests**: la evidencia sale en segundo plano, con reintentos y sin
|
|
17
|
+
duplicados; los shards pueden unirse a la misma ejecución.
|
|
18
|
+
- **Sin servidor**: si el servidor está caído o rechaza el token, la ejecución se graba en local;
|
|
19
|
+
`tracereports report <carpeta>` arma el reporte HTML y `tracereports push <carpeta>` lo sube después.
|
|
20
|
+
Con `TRACEREPORTS_OFFLINE=both` además guarda esa copia local mientras envía al servidor
|
|
21
|
+
([guía del reporte local](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/local-report.md)).
|
|
22
|
+
- **Configuración** desde el entorno o el `.env` del proyecto (`TRACEREPORTS_URL`, `TRACEREPORTS_TOKEN`…).
|
|
23
|
+
|
|
12
24
|
Documentación completa: [docs/es/javascript.md](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/javascript.md).
|
|
25
|
+
|
|
26
|
+
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,18 @@ 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**: screenshot and DOM on failure, and an API for steps and network.
|
|
16
|
+
- **Never slows down or breaks your tests**: evidence goes out in the background with retries and
|
|
17
|
+
no duplicates; shards can join the same run.
|
|
18
|
+
- **Without a server**: if the server is down or rejects the token, the run is recorded locally;
|
|
19
|
+
`tracereports report <folder>` builds the HTML report and `tracereports push <folder>` uploads it later.
|
|
20
|
+
With `TRACEREPORTS_OFFLINE=both` it also keeps that local copy while sending to the server
|
|
21
|
+
([local report guide](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/local-report.md)).
|
|
22
|
+
- **Configuration** from the environment or the project's `.env` (`TRACEREPORTS_URL`, `TRACEREPORTS_TOKEN`…).
|
|
23
|
+
|
|
12
24
|
Full documentation: [docs/en/javascript.md](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/javascript.md).
|
|
25
|
+
|
|
26
|
+
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.
|
|
3
|
+
"version": "0.2.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
|
-
/**
|
|
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
|
-
|
|
92
|
-
|
|
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 }
|
|
@@ -25,6 +35,8 @@ export interface Connection {
|
|
|
25
35
|
failed?: boolean; error_text?: string; started_at?: number; duration_ms?: number | null;
|
|
26
36
|
request_headers?: Record<string, string>; post_data?: string; response_headers?: Record<string, string>;
|
|
27
37
|
response_body?: string; body_size?: number; expected?: boolean;
|
|
38
|
+
/** The body was already cut before reaching the client (body_size keeps the original size). */
|
|
39
|
+
body_truncated?: boolean;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
export class TraceReports {
|
|
@@ -32,9 +44,13 @@ export class TraceReports {
|
|
|
32
44
|
baseUrl: string; runId: number | null; runCreated: boolean; enabled: boolean;
|
|
33
45
|
readonly delivery: Delivery;
|
|
34
46
|
readonly reportUrl: string;
|
|
47
|
+
/** True when evidence is recorded locally, including both mode. */
|
|
48
|
+
readonly recording: boolean;
|
|
49
|
+
offlineDir: string | null;
|
|
50
|
+
offlineReport: string | null;
|
|
35
51
|
deliveryProblems(): number;
|
|
36
52
|
startRun(name: string, opts?: RunOptions): Promise<number | null>;
|
|
37
|
-
joinRun(runId: number | string): number | null;
|
|
53
|
+
joinRun(runId: number | string, opts?: { offlineDir?: string }): number | null;
|
|
38
54
|
finishRun(opts?: { interrupted?: boolean }): Promise<unknown>;
|
|
39
55
|
flush(timeoutMs?: number): Promise<number>;
|
|
40
56
|
startTest(name: string, opts?: TestOptions): Promise<TraceTest>;
|
|
@@ -42,7 +58,13 @@ export class TraceReports {
|
|
|
42
58
|
test<T>(name: string, opts: TestOptions, fn: (t: TraceTest) => T | Promise<T>): Promise<T>;
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
export interface ConsoleEntry { level: "error" | "warning" | "pageerror" | "info" | "log" | "debug"; text: string; location?: string; timestamp?: number }
|
|
62
|
+
|
|
45
63
|
export class TraceTest {
|
|
64
|
+
/** Browser console of the test (its "Console" tab). Captured by the tracereports/playwright fixtures. */
|
|
65
|
+
console(entries: ConsoleEntry[]): boolean;
|
|
66
|
+
/** Attaches the Playwright trace (trace.zip) or the test video (WebM/MP4): a Buffer or a file path. */
|
|
67
|
+
artifact(kind: "trace" | "video", data: Buffer | Uint8Array | string, name?: string): boolean;
|
|
46
68
|
readonly id: number | null; readonly active: boolean; attempts: number;
|
|
47
69
|
log(status: Status, message: string, timestamp?: number): void;
|
|
48
70
|
info(message: string): void; pass(message: string): void; fail(message: string): void; warn(message: string): void; skip(message: string): void;
|
|
@@ -53,6 +75,10 @@ export class TraceTest {
|
|
|
53
75
|
finish(status?: Status | "", opts?: { errorMessage?: string; errorTrace?: string; error?: unknown; attempts?: number }): void;
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
/** Lotes de red (bytes del JSON enviado): el servidor acepta hasta 48 MiB por request. */
|
|
79
|
+
export const NETWORK_BATCH_BYTES: number;
|
|
80
|
+
export const NETWORK_ALONE_BYTES: number;
|
|
81
|
+
export function networkBatches(payload: object[], opts?: { maxCount?: number; maxBytes?: number; aloneBytes?: number }): string[];
|
|
56
82
|
export const DOM_SCRIPT: string;
|
|
57
83
|
|
|
58
84
|
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);
|
|
16
|
-
//
|
|
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
30
|
export const VERSION = "0.1.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
|
|
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.
|
|
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
|
-
/**
|
|
119
|
-
|
|
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
|
-
|
|
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,7 +308,7 @@ 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
|
|
|
@@ -224,6 +378,47 @@ export class TraceTest {
|
|
|
224
378
|
this.cr.emit("POST", `/api/v1/tests/${this.id}/screenshot`, body, `multipart/form-data; boundary=${boundary}`, this.cr.uploadTimeoutMs);
|
|
225
379
|
}
|
|
226
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Consola del navegador del test (pestaña "Consola"): [{level: "error"|"warning"|"pageerror"|...,
|
|
383
|
+
* text, location, timestamp}]. Con los fixtures de `tracereports/playwright` se captura sola.
|
|
384
|
+
*/
|
|
385
|
+
console(entries) {
|
|
386
|
+
if (!this.active || !entries?.length) return false;
|
|
387
|
+
return this.cr.emit("POST", `/api/v1/tests/${this.id}/console`, JSON.stringify({ entries: entries.slice(0, 500) }), "application/json",
|
|
388
|
+
Math.max(this.cr.uploadTimeoutMs, 10_000));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Adjunta el trace de Playwright (`kind: "trace"`, el trace.zip) o el video del test
|
|
393
|
+
* (`kind: "video"`, WebM o MP4): Buffer o ruta. Hasta 100 MB; el reporte muestra el video y abre el
|
|
394
|
+
* trace en el Trace Viewer.
|
|
395
|
+
*/
|
|
396
|
+
artifact(kind, data, name = "") {
|
|
397
|
+
if (!this.active || !data) return false;
|
|
398
|
+
let body = data;
|
|
399
|
+
if (typeof data === "string") {
|
|
400
|
+
try {
|
|
401
|
+
body = fs.readFileSync(data);
|
|
402
|
+
name ||= path.basename(data);
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (body.length > 100 << 20) {
|
|
408
|
+
console.warn(`tracereports: ${name || kind} pesa más de 100 MB: no se adjunta`);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
const boundary = `----tracereports${Math.random().toString(16).slice(2)}`;
|
|
412
|
+
const part = (n, v) => `--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`;
|
|
413
|
+
const multipart = Buffer.concat([
|
|
414
|
+
Buffer.from(part("kind", kind) + part("name", String(name).replace(/[\r\n"]/g, " "))),
|
|
415
|
+
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="artifact"\r\nContent-Type: application/octet-stream\r\n\r\n`),
|
|
416
|
+
Buffer.from(body),
|
|
417
|
+
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
418
|
+
]);
|
|
419
|
+
return this.cr.emit("POST", `/api/v1/tests/${this.id}/artifact`, multipart, `multipart/form-data; boundary=${boundary}`, Math.max(this.cr.uploadTimeoutMs, 60_000));
|
|
420
|
+
}
|
|
421
|
+
|
|
227
422
|
/**
|
|
228
423
|
* Declara una respuesta negativa que el test verifica a propósito (p. ej. un 401 con
|
|
229
424
|
* credenciales inválidas): no cuenta como error ni como causa del fallo. `url`: texto contenido
|
|
@@ -256,14 +451,14 @@ export class TraceTest {
|
|
|
256
451
|
error_text: String(c.error_text || ""), started_at: Math.round(c.started_at || 0),
|
|
257
452
|
duration_ms: c.duration_ms == null ? null : Math.round(c.duration_ms),
|
|
258
453
|
request_headers: c.request_headers || {}, post_data: String(c.post_data || ""),
|
|
259
|
-
|
|
260
|
-
|
|
454
|
+
// el body pudo llegar ya recortado (captura): su indicador y su tamaño original (bytes) se conservan
|
|
455
|
+
response_headers: c.response_headers || {}, response_body: cut(body, limit),
|
|
456
|
+
body_size: Math.max(Number(c.body_size || 0), Buffer.byteLength(body)), body_truncated: Boolean(c.body_truncated) || body.length > limit,
|
|
261
457
|
expected: Boolean(c.expected) || this.isExpected(c),
|
|
262
458
|
};
|
|
263
459
|
});
|
|
264
|
-
for (
|
|
265
|
-
this.cr.emit("POST", `/api/v1/tests/${this.id}/network`,
|
|
266
|
-
"application/json", this.cr.uploadTimeoutMs);
|
|
460
|
+
for (const body of networkBatches(payload, { maxCount: batchSize })) {
|
|
461
|
+
this.cr.emit("POST", `/api/v1/tests/${this.id}/network`, body, "application/json", this.cr.uploadTimeoutMs);
|
|
267
462
|
}
|
|
268
463
|
return { stored: payload.length, errors: payload.filter((p) => !p.expected && (p.failed || p.status >= 400)).length };
|
|
269
464
|
}
|
|
@@ -294,3 +489,17 @@ export class TraceTest {
|
|
|
294
489
|
|
|
295
490
|
export { DOM_SCRIPT, domSnapshotInPage } from "./dom.js";
|
|
296
491
|
|
|
492
|
+
function findInPath(name) {
|
|
493
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE").split(";") : [""];
|
|
494
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
495
|
+
for (const ext of exts) {
|
|
496
|
+
const file = path.join(dir, name + ext.toLowerCase());
|
|
497
|
+
try {
|
|
498
|
+
if (dir && fs.statSync(file).isFile()) return file;
|
|
499
|
+
} catch {
|
|
500
|
+
// no está aquí
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
package/src/mirror.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Copia de cada llamada lógica; los reintentos siguen siendo responsabilidad del Sender.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { Recorder, MARKER } from "./offline.js";
|
|
6
|
+
|
|
7
|
+
// Un proceso tiene el bloqueo unos milisegundos: uno más viejo quedó de un proceso que murió.
|
|
8
|
+
const STALE_LOCK_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export class Mirror {
|
|
11
|
+
constructor(sender, directory, server, runId, payload) {
|
|
12
|
+
this.sender = sender;
|
|
13
|
+
this.directory = directory;
|
|
14
|
+
this.runs = new Map();
|
|
15
|
+
this.tests = new Map();
|
|
16
|
+
this.failed = false;
|
|
17
|
+
this.closed = false;
|
|
18
|
+
this.disabled = false;
|
|
19
|
+
this.active = 0;
|
|
20
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
21
|
+
this.locked(() => {
|
|
22
|
+
const marker = path.join(directory, MARKER);
|
|
23
|
+
if (fs.existsSync(marker) && JSON.parse(fs.readFileSync(marker)).raw_removed) throw new Error("la carpeta solo contiene el reporte; usa otra carpeta");
|
|
24
|
+
this.recorder = new Recorder(directory);
|
|
25
|
+
this.status = `status-${process.pid}-${this.recorder.tag}.json`;
|
|
26
|
+
this.write(this.status, { complete: false, reason: "active" });
|
|
27
|
+
const m = JSON.parse(fs.readFileSync(marker));
|
|
28
|
+
if (m.mirror && m.mirror.server !== server) throw new Error("la carpeta pertenece a otro servidor");
|
|
29
|
+
m.mirror ||= { server, runs: [], complete: false };
|
|
30
|
+
m.mirror.complete = false;
|
|
31
|
+
this.write(MARKER, m);
|
|
32
|
+
let local;
|
|
33
|
+
const mapping = path.join(directory, "ids", `server-${runId}`);
|
|
34
|
+
if (payload) {
|
|
35
|
+
local = this.recorder.record("POST", "/api/v1/runs", JSON.stringify(payload), "application/json").run_id;
|
|
36
|
+
fs.writeFileSync(mapping, String(local), { flag: "wx" });
|
|
37
|
+
m.mirror.runs.push({ local, server: runId });
|
|
38
|
+
this.write(MARKER, m);
|
|
39
|
+
} else {
|
|
40
|
+
try {
|
|
41
|
+
local = Number(fs.readFileSync(mapping, "utf8"));
|
|
42
|
+
if (!Number.isSafeInteger(local) || local >= 0) throw new Error("id local inválido");
|
|
43
|
+
} catch (err) {
|
|
44
|
+
this.failed = true;
|
|
45
|
+
console.warn(`tracereports: falta el id local de la ejecución #${runId}: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (local) this.runs.set(runId, local);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
locked(fn) {
|
|
53
|
+
const lock = path.join(this.directory, ".mirror-lock");
|
|
54
|
+
for (let n = 0; ; n++) {
|
|
55
|
+
try { fs.mkdirSync(lock); break; } catch (err) {
|
|
56
|
+
if (err.code !== "EEXIST") throw err;
|
|
57
|
+
if (n >= 50) {
|
|
58
|
+
let stale = false;
|
|
59
|
+
try { stale = Date.now() - fs.statSync(lock).mtimeMs > STALE_LOCK_MS; } catch { /* ya no existe */ }
|
|
60
|
+
if (!stale) throw new Error(`${lock} lo tiene otro proceso (bórralo si ninguna ejecución usa esta carpeta)`);
|
|
61
|
+
try { fs.rmdirSync(lock); } catch { /* otro proceso lo retiró antes */ }
|
|
62
|
+
n = 0; // huérfano: se retira y se vuelve a intentar
|
|
63
|
+
}
|
|
64
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try { return fn(); } finally { fs.rmdirSync(lock); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
write(name, value) {
|
|
71
|
+
const dest = path.join(this.directory, name), tmp = `${dest}.${randomUUID()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
fs.writeFileSync(tmp, JSON.stringify(value));
|
|
74
|
+
try { fs.renameSync(tmp, dest); }
|
|
75
|
+
catch (err) {
|
|
76
|
+
// Windows can reject a replacement rename even while this process owns the lock.
|
|
77
|
+
if (err.code !== "EPERM" && err.code !== "EACCES") throw err;
|
|
78
|
+
fs.copyFileSync(tmp, dest);
|
|
79
|
+
fs.rmSync(tmp, { force: true });
|
|
80
|
+
}
|
|
81
|
+
} finally { fs.rmSync(tmp, { force: true }); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
diskError(err) {
|
|
85
|
+
this.failed = true;
|
|
86
|
+
if (!this.disabled) console.warn(`tracereports: se detuvo la copia local en ${this.directory}: ${err.message}`);
|
|
87
|
+
this.disabled = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
record(method, apiPath, body, contentType) {
|
|
91
|
+
if (this.disabled || this.closed) return null;
|
|
92
|
+
try {
|
|
93
|
+
const localPath = apiPath.replace(/^\/api\/v1\/(runs|tests)\/(-?\d+)/, (match, kind, id) => {
|
|
94
|
+
const ids = kind === "runs" ? this.runs : this.tests;
|
|
95
|
+
let local = ids.get(Number(id));
|
|
96
|
+
if (Number(id) > 0 && !local) {
|
|
97
|
+
this.failed = true;
|
|
98
|
+
// Referencia local sin creación: replay la omite, nunca modifica un run remoto ajeno.
|
|
99
|
+
local = this.recorder.localId();
|
|
100
|
+
ids.set(Number(id), local);
|
|
101
|
+
}
|
|
102
|
+
return `/api/v1/${kind}/${local ?? id}`;
|
|
103
|
+
});
|
|
104
|
+
return this.recorder.record(method, localPath, body, contentType);
|
|
105
|
+
} catch (err) { this.diskError(err); return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async sendNow(method, apiPath, body, contentType, ...args) {
|
|
109
|
+
this.active++;
|
|
110
|
+
const local = this.record(method, apiPath, body, contentType);
|
|
111
|
+
try {
|
|
112
|
+
const remote = /\/(runs|tests)\/-\d+/.test(apiPath) ? null : await this.sender.sendNow(method, apiPath, body, contentType, ...args);
|
|
113
|
+
if (remote == null) this.failed = true;
|
|
114
|
+
if (local?.test_id && remote?.test_id) this.tests.set(remote.test_id, local.test_id);
|
|
115
|
+
// Si el servidor ya no crea tests, la evidencia posterior sigue usando el id local.
|
|
116
|
+
return remote ?? (local?.test_id ? local : null);
|
|
117
|
+
} finally { this.active--; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
enqueue(method, apiPath, body, contentType, ...args) {
|
|
121
|
+
this.record(method, apiPath, body, contentType);
|
|
122
|
+
if (/\/(runs|tests)\/-\d+/.test(apiPath)) { this.failed = true; return false; }
|
|
123
|
+
const ok = this.sender.enqueue(method, apiPath, body, contentType, ...args);
|
|
124
|
+
if (!ok) this.failed = true;
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get stats() { return { ...this.sender.stats, recorded: this.recorder.stats.recorded }; }
|
|
129
|
+
get pending() { return this.sender.pending; }
|
|
130
|
+
serverDown() { return this.sender.serverDown(); }
|
|
131
|
+
flush(...args) { return this.sender.flush(...args); }
|
|
132
|
+
abandon() { return this.sender.abandon(); }
|
|
133
|
+
|
|
134
|
+
close(complete) {
|
|
135
|
+
if (this.closed) return;
|
|
136
|
+
this.closed = true;
|
|
137
|
+
try {
|
|
138
|
+
const ok = complete && !this.failed && this.active === 0;
|
|
139
|
+
this.locked(() => this.write(this.status, { complete: ok, reason: ok ? "" : "delivery or recording incomplete" }));
|
|
140
|
+
} catch (err) { this.diskError(err); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
snapshot() {
|
|
144
|
+
const files = fs.readdirSync(this.directory).filter(n => /^status-.*\.json$/.test(n)).sort();
|
|
145
|
+
const events = fs.readdirSync(this.directory).filter(n => /^events-.*\.jsonl$/.test(n));
|
|
146
|
+
if (!files.length || events.some(n => !files.includes(n.replace(/^events-/, "status-").replace(/\.jsonl$/, ".json")))) return null;
|
|
147
|
+
const entries = files.map(n => [n, fs.readFileSync(path.join(this.directory, n), "utf8")]);
|
|
148
|
+
return entries.every(([, raw]) => JSON.parse(raw).complete === true) ? JSON.stringify(entries) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
prepare() {
|
|
152
|
+
try { return this.locked(() => this.snapshot()); } catch (err) { this.diskError(err); return null; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
finalize(report, keep, before) {
|
|
156
|
+
try {
|
|
157
|
+
this.locked(() => {
|
|
158
|
+
const snapshot = this.snapshot();
|
|
159
|
+
const marker = JSON.parse(fs.readFileSync(path.join(this.directory, MARKER)));
|
|
160
|
+
marker.mirror.complete = !this.failed && snapshot !== null;
|
|
161
|
+
const remove = marker.mirror.complete && before === snapshot && report && fs.existsSync(report) && !keep;
|
|
162
|
+
// Se marca antes de borrar: una interrupción no deja una grabación parcial subible.
|
|
163
|
+
if (remove) marker.raw_removed = true;
|
|
164
|
+
this.write(MARKER, marker);
|
|
165
|
+
if (remove) {
|
|
166
|
+
for (const n of fs.readdirSync(this.directory)) {
|
|
167
|
+
if (/^(events-.*\.jsonl|status-.*\.json)$/.test(n) || ["bodies", "ids"].includes(n)) fs.rmSync(path.join(this.directory, n), { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
} catch (err) { this.diskError(err); }
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/offline.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Grabación local cuando no hay servidor (no responde o rechaza el token): en vez de perder la
|
|
2
|
+
// evidencia, el cliente escribe en una carpeta las mismas llamadas a la API que habría hecho.
|
|
3
|
+
// Después: `tracereports report <carpeta>` arma el reporte HTML y `tracereports push <carpeta>`
|
|
4
|
+
// la sube al servidor. Mismo formato que el cliente Python (lo lee el binario: internal/offline).
|
|
5
|
+
|
|
6
|
+
import { randomInt, randomUUID } from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
export const FORMAT_VERSION = 1;
|
|
11
|
+
export const MARKER = "tracereports-offline.json";
|
|
12
|
+
|
|
13
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
14
|
+
|
|
15
|
+
// Ids locales: cada grabador reserva bloques de ID_BLOCK ids creando <carpeta>/ids/<bloque> en
|
|
16
|
+
// exclusiva, así dos grabadores que comparten la carpeta (mismo proceso, otro proceso u otra
|
|
17
|
+
// sesión) nunca repiten un id. El bloque es aleatorio en [MIN_BLOCK, MAX_BLOCK): no choca con los
|
|
18
|
+
// ids de grabaciones anteriores (pid % 10^6) y -(bloque * ID_BLOCK + n) sigue siendo un entero
|
|
19
|
+
// exacto en JavaScript (< 2^53). Mismo esquema en los clientes Python, Java y Go.
|
|
20
|
+
const ID_BLOCK = 100_000;
|
|
21
|
+
const MIN_BLOCK = 1_000_000;
|
|
22
|
+
const MAX_BLOCK = 90_000_000_000;
|
|
23
|
+
|
|
24
|
+
/** Carpeta nueva para una sesión dentro de `base` (no mezcla corridas distintas). */
|
|
25
|
+
export function sessionSlug(name) {
|
|
26
|
+
const ascii = String(name || "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
27
|
+
return (ascii.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "") || "run");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function newSessionDir(base, name = "") {
|
|
31
|
+
const d = new Date();
|
|
32
|
+
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
33
|
+
return path.join(base, `${sessionSlug(name)}-${stamp}-${randomUUID().replace(/-/g, "").slice(0, 6)}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Reemplaza al Sender: graba cada llamada y responde con ids locales (negativos). */
|
|
37
|
+
export class Recorder {
|
|
38
|
+
constructor(directory) {
|
|
39
|
+
this.directory = directory;
|
|
40
|
+
this.stats = { sent: 0, retried: 0, rejected: 0, dropped: 0, lost: 0, recorded: 0 };
|
|
41
|
+
this.seq = 0;
|
|
42
|
+
this.nextId = 0;
|
|
43
|
+
this.block = 0;
|
|
44
|
+
this.tag = randomUUID().slice(0, 8);
|
|
45
|
+
fs.mkdirSync(path.join(directory, "bodies"), { recursive: true });
|
|
46
|
+
try {
|
|
47
|
+
fs.writeFileSync(path.join(directory, MARKER),
|
|
48
|
+
JSON.stringify({ format: "tracereports-offline", version: FORMAT_VERSION, id: randomUUID().replace(/-/g, "") }), { flag: "wx" });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code !== "EEXIST") throw err; // otro proceso ya lo creó
|
|
51
|
+
}
|
|
52
|
+
this.file = path.join(directory, `events-${process.pid}-${this.tag}.jsonl`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ids locales negativos, únicos entre los grabadores que graban en la misma carpeta
|
|
56
|
+
localId() {
|
|
57
|
+
if (!this.block || this.nextId >= ID_BLOCK - 1) {
|
|
58
|
+
this.block = this.reserveBlock();
|
|
59
|
+
this.nextId = 0;
|
|
60
|
+
}
|
|
61
|
+
this.nextId++;
|
|
62
|
+
return -(this.block * ID_BLOCK + this.nextId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
reserveBlock() {
|
|
66
|
+
const ids = path.join(this.directory, "ids");
|
|
67
|
+
fs.mkdirSync(ids, { recursive: true });
|
|
68
|
+
for (let i = 0; i < 100; i++) {
|
|
69
|
+
const block = randomInt(MIN_BLOCK, MAX_BLOCK);
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(path.join(ids, String(block)), "", { flag: "wx" });
|
|
72
|
+
return block;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code !== "EEXIST") throw err; // ya es de otro grabador: se prueba otro
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`tracereports: could not reserve local ids in ${ids}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
record(method, apiPath, body, contentType) {
|
|
81
|
+
this.seq++;
|
|
82
|
+
const event = { seq: this.seq, ts: Date.now(), method, path: apiPath, content_type: contentType };
|
|
83
|
+
let parsed;
|
|
84
|
+
if (contentType.startsWith("application/json")) {
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(body?.length ? body.toString() : "{}");
|
|
87
|
+
} catch {
|
|
88
|
+
parsed = undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (parsed !== undefined) event.body = parsed;
|
|
92
|
+
else {
|
|
93
|
+
const name = `bodies/${process.pid}-${this.tag}-${this.seq}.bin`;
|
|
94
|
+
fs.writeFileSync(path.join(this.directory, name), body ?? "");
|
|
95
|
+
event.body_file = name;
|
|
96
|
+
}
|
|
97
|
+
let res = {};
|
|
98
|
+
if (method === "POST" && apiPath === "/api/v1/runs") res = { run_id: this.localId() };
|
|
99
|
+
else if (method === "POST" && apiPath.startsWith("/api/v1/runs/") && apiPath.endsWith("/tests")) res = { test_id: this.localId() };
|
|
100
|
+
const id = res.run_id ?? res.test_id;
|
|
101
|
+
if (id) event.local_id = id;
|
|
102
|
+
fs.appendFileSync(this.file, JSON.stringify(event) + "\n"); // síncrono: si el proceso muere, lo escrito queda
|
|
103
|
+
this.stats.recorded++;
|
|
104
|
+
return res;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── interfaz del Sender
|
|
108
|
+
async sendNow(method, apiPath, body, contentType) {
|
|
109
|
+
return this.record(method, apiPath, body, contentType);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
enqueue(method, apiPath, body, contentType) {
|
|
113
|
+
this.record(method, apiPath, body, contentType);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get pending() {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
serverDown() {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async flush() {
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
abandon() {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/playwright.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// Con la `page` del test:
|
|
13
13
|
// - captura las llamadas HTTP que hace el navegador (pestaña "Red"; es el tráfico visto desde el
|
|
14
14
|
// navegador, no los logs del backend);
|
|
15
|
+
// - captura la consola: console.error, console.warn y los errores de JavaScript sin manejar
|
|
16
|
+
// (pestaña "Consola");
|
|
15
17
|
// - si el test falla: snapshot del DOM (recomendador de locators) y una captura si el proyecto
|
|
16
18
|
// no tiene `screenshot: "only-on-failure"`.
|
|
17
19
|
// Todo viaja como attachments del test; el reporter `tracereports/reporter` los sube.
|
|
@@ -66,7 +68,12 @@ export function captureNetwork(page, { apiPatterns = ["/api/"] } = {}) {
|
|
|
66
68
|
const isApi = BODY_TYPES.has(c.resource_type) || apiPatterns.some((p) => c.url.includes(p));
|
|
67
69
|
if (isApi || c.status >= 400) {
|
|
68
70
|
const p = res.text()
|
|
69
|
-
.then((body) => {
|
|
71
|
+
.then((body) => {
|
|
72
|
+
// el recorte se marca aquí, donde ocurre; el tamaño original va en bytes UTF-8 (como el servidor)
|
|
73
|
+
c.body_size = Buffer.byteLength(body);
|
|
74
|
+
c.body_truncated = body.length > MAX_BODY;
|
|
75
|
+
c.response_body = body.length > MAX_BODY ? body.slice(0, /[\uD800-\uDBFF]/.test(body[MAX_BODY - 1]) ? MAX_BODY - 1 : MAX_BODY) : body;
|
|
76
|
+
})
|
|
70
77
|
.catch(() => {})
|
|
71
78
|
.finally(() => pending.delete(p));
|
|
72
79
|
pending.add(p);
|
|
@@ -99,6 +106,32 @@ export function captureNetwork(page, { apiPatterns = ["/api/"] } = {}) {
|
|
|
99
106
|
};
|
|
100
107
|
}
|
|
101
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Engancha la captura de la consola a una página: errores y advertencias, y los errores de
|
|
111
|
+
* JavaScript que la página no manejó. Devuelve un colector con `drain()`.
|
|
112
|
+
*/
|
|
113
|
+
export function captureConsole(page) {
|
|
114
|
+
const entries = [];
|
|
115
|
+
let reported = 0;
|
|
116
|
+
page.on("console", (msg) => {
|
|
117
|
+
const level = msg.type() === "warning" ? "warning" : msg.type();
|
|
118
|
+
if (level !== "error" && level !== "warning") return;
|
|
119
|
+
const loc = msg.location?.() || {};
|
|
120
|
+
entries.push({ level, text: msg.text(), location: loc.url ? `${loc.url}:${loc.lineNumber}:${loc.columnNumber}` : "", timestamp: Date.now() });
|
|
121
|
+
});
|
|
122
|
+
page.on("pageerror", (err) => {
|
|
123
|
+
entries.push({ level: "pageerror", text: err?.stack || String(err), location: "", timestamp: Date.now() });
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
entries,
|
|
127
|
+
drain() {
|
|
128
|
+
const out = entries.slice(reported);
|
|
129
|
+
reported = entries.length;
|
|
130
|
+
return out;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
102
135
|
/** Snapshot de los elementos de la página, o null si ya no está disponible. */
|
|
103
136
|
export async function captureDom(page) {
|
|
104
137
|
try {
|
|
@@ -122,12 +155,17 @@ export function withTraceReports(baseTest) {
|
|
|
122
155
|
const fixtures = {
|
|
123
156
|
page: async ({ page }, use, testInfo) => {
|
|
124
157
|
const net = captureNetwork(page);
|
|
158
|
+
const cons = captureConsole(page);
|
|
125
159
|
await use(page);
|
|
126
160
|
await net.settle(2000).catch(() => {});
|
|
127
161
|
const conns = net.drain();
|
|
128
162
|
if (conns.length) {
|
|
129
163
|
await testInfo.attach("tracereports-network", { body: JSON.stringify(conns), contentType: "application/json" });
|
|
130
164
|
}
|
|
165
|
+
const logs = cons.drain();
|
|
166
|
+
if (logs.length) {
|
|
167
|
+
await testInfo.attach("tracereports-console", { body: JSON.stringify(logs.slice(0, 500)), contentType: "application/json" });
|
|
168
|
+
}
|
|
131
169
|
if (testInfo.status !== testInfo.expectedStatus) {
|
|
132
170
|
const snap = await captureDom(page);
|
|
133
171
|
if (snap) await testInfo.attach("tracereports-dom", { body: JSON.stringify(snap), contentType: "application/json" });
|
package/src/reporter.js
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
// testInfo.attach con imagen) y, con los fixtures de `tracereports/playwright`, la red y el DOM.
|
|
12
12
|
// - Al terminar espera la cola y cierra la ejecución; imprime el link. TRACEREPORTS_STRICT=1 hace fallar
|
|
13
13
|
// la corrida si parte de la evidencia no llegó al servidor.
|
|
14
|
+
// - Sin servidor (no responde o el token es incorrecto) la evidencia se graba en
|
|
15
|
+
// ./tracereports-offline/<sesión>: `tracereports report <carpeta>` arma el reporte HTML.
|
|
14
16
|
//
|
|
15
17
|
// Opciones (o variables): url (TRACEREPORTS_URL), token (TRACEREPORTS_TOKEN), runName (TRACEREPORTS_RUN_NAME),
|
|
16
|
-
// environment (TRACEREPORTS_ENV), project (TRACEREPORTS_PROJECT), strict (TRACEREPORTS_STRICT)
|
|
18
|
+
// environment (TRACEREPORTS_ENV), project (TRACEREPORTS_PROJECT), strict (TRACEREPORTS_STRICT),
|
|
19
|
+
// offlineDir (TRACEREPORTS_OFFLINE_DIR), offlineBase (TRACEREPORTS_OFFLINE_BASE),
|
|
20
|
+
// offline: "auto" | "always" | "both" | "off" (TRACEREPORTS_OFFLINE).
|
|
17
21
|
|
|
18
22
|
import fs from "node:fs";
|
|
19
23
|
import path from "node:path";
|
|
@@ -26,7 +30,7 @@ const truthy = (v) => ["1", "true", "yes"].includes(String(v || "").toLowerCase(
|
|
|
26
30
|
export default class TraceReportsReporter {
|
|
27
31
|
constructor(options = {}) {
|
|
28
32
|
this.options = options;
|
|
29
|
-
this.cr = new TraceReports({ baseUrl: options.url, token: options.token });
|
|
33
|
+
this.cr = new TraceReports({ baseUrl: options.url, token: options.token, offlineDir: options.offlineDir, offlineBase: options.offlineBase, offline: options.offline });
|
|
30
34
|
this.tests = new Map(); // test.id -> { ct: Promise<TraceTest>, lastError }
|
|
31
35
|
this.steps = new Map(); // test.id -> intento -> pasos (test.step)
|
|
32
36
|
this.strict = options.strict ?? truthy(env("STRICT"));
|
|
@@ -132,8 +136,12 @@ export default class TraceReportsReporter {
|
|
|
132
136
|
if (!body) continue;
|
|
133
137
|
if (att.name === "tracereports-network") ct.network(JSON.parse(body.toString()));
|
|
134
138
|
else if (att.name === "tracereports-dom") ct.dom(JSON.parse(body.toString()));
|
|
139
|
+
else if (att.name === "tracereports-console") ct.console(JSON.parse(body.toString()));
|
|
135
140
|
else if (att.contentType?.startsWith("image/")) ct.screenshot(body, att.name === "screenshot" ? "Captura al fallar" : att.name,
|
|
136
141
|
unexpected ? "FAIL" : "INFO");
|
|
142
|
+
// trace: "on" / "retain-on-failure" / "on-first-retry"; video: lo mismo en use.video
|
|
143
|
+
else if (att.name === "trace" && att.contentType === "application/zip") ct.artifact("trace", body, `trace-${result.retry + 1}.zip`);
|
|
144
|
+
else if (att.contentType?.startsWith("video/")) ct.artifact("video", body, att.name === "video" ? `video-${result.retry + 1}` : att.name);
|
|
137
145
|
}
|
|
138
146
|
const final = asExpected || skipped || result.retry >= test.retries;
|
|
139
147
|
if (!final) {
|
|
@@ -161,9 +169,16 @@ export default class TraceReportsReporter {
|
|
|
161
169
|
await this.started;
|
|
162
170
|
const interrupted = result.status === "interrupted" || result.status === "timedout";
|
|
163
171
|
await this.cr.finishRun({ interrupted });
|
|
164
|
-
const problems = this.cr.deliveryProblems();
|
|
165
|
-
if (this.cr.
|
|
166
|
-
|
|
172
|
+
const problems = this.cr.deliveryProblems() + (this.cr.recording && this.cr.runId < 0 && ["auto", "both"].includes(this.cr.offlineMode) ? 1 : 0);
|
|
173
|
+
if (this.cr.recording && this.cr.runId > 0) {
|
|
174
|
+
console.log(`TraceReports: ${this.cr.reportUrl}`);
|
|
175
|
+
console.log(`TraceReports copia local: ${this.cr.offlineReport || `tracereports report ${this.cr.offlineDir}`}`);
|
|
176
|
+
} else if (this.cr.recording) {
|
|
177
|
+
console.log(`TraceReports (sin servidor): evidencia en ${this.cr.offlineDir}; reporte: ` +
|
|
178
|
+
(this.cr.reportUrl || `\`tracereports report ${this.cr.offlineDir} -o reporte\``) +
|
|
179
|
+
`; para subirla: \`tracereports push ${this.cr.offlineDir}\``);
|
|
180
|
+
} else if (this.cr.runId) console.log(`TraceReports: ${this.cr.reportUrl}`);
|
|
181
|
+
if (problems && (!this.cr.recording || this.cr.runId > 0)) {
|
|
167
182
|
const d = this.cr.delivery;
|
|
168
183
|
console.warn(`TraceReports: atención: ${problems - d.runNotClosed} eventos de evidencia no llegaron al servidor ` +
|
|
169
184
|
`(enviados ${d.sent}, rechazados ${d.rejected}, descartados ${d.dropped}, perdidos ${d.lost})` +
|
package/src/transport.js
CHANGED
|
@@ -33,6 +33,7 @@ export class Sender {
|
|
|
33
33
|
this.downUntil = 0;
|
|
34
34
|
this.running = false;
|
|
35
35
|
this.waiters = [];
|
|
36
|
+
this.pause = null; // backoff en curso: {timer, wake}, para que abandon() lo corte
|
|
36
37
|
this.stats = { sent: 0, retried: 0, rejected: 0, dropped: 0, lost: 0 };
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -118,7 +119,7 @@ export class Sender {
|
|
|
118
119
|
item.attempts++;
|
|
119
120
|
this.stats.retried++;
|
|
120
121
|
this.queue.unshift(item);
|
|
121
|
-
await
|
|
122
|
+
await this.backoff(Math.min(limits.maxBackoffMs, 500 * 2 ** Math.min(item.attempts, 6)));
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
124
125
|
this.bytes -= item.size;
|
|
@@ -134,10 +135,30 @@ export class Sender {
|
|
|
134
135
|
for (const w of this.waiters.splice(0)) w();
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
/** Espera
|
|
138
|
+
/** Espera de un reintento que abandon() puede cortar (si no, mantendría vivo el proceso). */
|
|
139
|
+
backoff(ms) {
|
|
140
|
+
return new Promise((wake) => {
|
|
141
|
+
const timer = setTimeout(() => { this.pause = null; wake(); }, ms);
|
|
142
|
+
this.pause = { timer, wake };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Espera a que la cola se vacíe (máx. timeoutMs). Devuelve lo que quedó pendiente. Lo que gana
|
|
148
|
+
* cancela lo otro: si se vacía, el temporizador del timeout se borra (no retiene el proceso);
|
|
149
|
+
* si vence, el waiter sale de la lista.
|
|
150
|
+
*/
|
|
138
151
|
async flush(timeoutMs) {
|
|
139
152
|
if (!this.pending) return 0;
|
|
140
|
-
|
|
153
|
+
let timer, waiter;
|
|
154
|
+
await new Promise((resolve) => {
|
|
155
|
+
waiter = resolve;
|
|
156
|
+
this.waiters.push(waiter);
|
|
157
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
158
|
+
});
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
const i = this.waiters.indexOf(waiter);
|
|
161
|
+
if (i >= 0) this.waiters.splice(i, 1);
|
|
141
162
|
return this.pending;
|
|
142
163
|
}
|
|
143
164
|
|
|
@@ -149,6 +170,13 @@ export class Sender {
|
|
|
149
170
|
this.inflight = null;
|
|
150
171
|
this.bytes = 0;
|
|
151
172
|
this.stats.lost += items.length;
|
|
173
|
+
// nada queda esperando: el backoff en curso termina ya y los flush pendientes vuelven
|
|
174
|
+
if (this.pause) {
|
|
175
|
+
clearTimeout(this.pause.timer);
|
|
176
|
+
this.pause.wake();
|
|
177
|
+
this.pause = null;
|
|
178
|
+
}
|
|
179
|
+
this.notify();
|
|
152
180
|
if (items.length) {
|
|
153
181
|
console.warn(`tracereports: ${items.length} eventos de evidencia no se pudieron enviar y se perdieron`);
|
|
154
182
|
}
|