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 +15 -0
- package/README.md +15 -0
- package/package.json +3 -2
- package/src/binary.js +125 -0
- package/src/env.js +8 -5
- package/src/index.d.ts +31 -1
- package/src/index.js +232 -19
- 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/selenium.js +193 -7
- package/src/transport.js +31 -3
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/selenium.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
// Selenium WebDriver (selenium-webdriver) con TraceReports. Sirve con
|
|
2
|
-
// (node:test, Mocha, Jest…): no importa selenium-webdriver, usa el `driver` que le
|
|
1
|
+
// Selenium WebDriver (selenium-webdriver) con TraceReports: red, consola, captura y DOM. Sirve con
|
|
2
|
+
// cualquier runner (node:test, Mocha, Jest…): no importa selenium-webdriver, usa el `driver` que le
|
|
3
|
+
// pases.
|
|
3
4
|
//
|
|
5
|
+
// import { Builder } from "selenium-webdriver";
|
|
6
|
+
// import chrome from "selenium-webdriver/chrome.js";
|
|
4
7
|
// import { TraceReports } from "tracereports";
|
|
5
|
-
// import { withDriver, screenshot } from "tracereports/selenium";
|
|
8
|
+
// import { seleniumOptions, withDriver, screenshot } from "tracereports/selenium";
|
|
9
|
+
//
|
|
10
|
+
// const options = seleniumOptions(new chrome.Options()); // activa los logs de red y de consola
|
|
11
|
+
// const driver = await new Builder().forBrowser("chrome").setChromeOptions(options).build();
|
|
6
12
|
//
|
|
7
13
|
// const cr = new TraceReports();
|
|
8
14
|
// await cr.startRun("Regresión web", { environment: "chrome" });
|
|
@@ -12,12 +18,27 @@
|
|
|
12
18
|
// });
|
|
13
19
|
// await cr.finishRun();
|
|
14
20
|
//
|
|
15
|
-
//
|
|
16
|
-
// reporte recomienda selectores cuando se rompe un locator).
|
|
17
|
-
//
|
|
21
|
+
// `withDriver` envía la red (pestaña "Red", Lab) y la consola de cada test, y si falla la captura del
|
|
22
|
+
// momento y el snapshot del DOM (con él el reporte recomienda selectores cuando se rompe un locator).
|
|
23
|
+
// La red y la consola salen del log de rendimiento de Chrome y Edge (Firefox no lo tiene): hay que
|
|
24
|
+
// crear el driver con `seleniumOptions(options)`. El body de una llamada hecha antes de que la
|
|
25
|
+
// página navegara puede no estar ya disponible; el resto de la llamada sí.
|
|
18
26
|
|
|
19
27
|
import { DOM_SCRIPT } from "./dom.js";
|
|
20
28
|
|
|
29
|
+
const LOG_PREFS = { performance: "ALL", browser: "ALL" };
|
|
30
|
+
const BODY_TYPES = new Set(["xhr", "fetch"]);
|
|
31
|
+
const TEXT_MIME = ["json", "text", "xml", "javascript", "html", "x-www-form-urlencoded", "graphql"];
|
|
32
|
+
|
|
33
|
+
/** Activa en las opciones de Chrome o Edge los logs de rendimiento (red) y del navegador (consola). */
|
|
34
|
+
export function seleniumOptions(options) {
|
|
35
|
+
options.set("goog:loggingPrefs", { ...LOG_PREFS });
|
|
36
|
+
if (/edge/i.test(options?.constructor?.name || "") || options.get?.("browserName") === "MicrosoftEdge") {
|
|
37
|
+
options.set("ms:loggingPrefs", { ...LOG_PREFS });
|
|
38
|
+
}
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/** Captura de pantalla como base64 (lo que acepta `t.screenshot`), o null. */
|
|
22
43
|
export async function screenshot(driver) {
|
|
23
44
|
try {
|
|
@@ -36,14 +57,179 @@ export async function captureDom(driver) {
|
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
|
|
60
|
+
function* events(entries) {
|
|
61
|
+
for (const entry of entries || []) {
|
|
62
|
+
let msg;
|
|
63
|
+
try {
|
|
64
|
+
msg = JSON.parse(entry.message).message;
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (msg?.method?.startsWith("Network.")) yield [msg.method, msg.params || {}];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convierte las entradas del log "performance" (`driver.manage().logs().get("performance")`) en
|
|
74
|
+
* conexiones (el formato de `t.network`). Cada una lleva `_requestId` para pedir su body después.
|
|
75
|
+
*/
|
|
76
|
+
export function connectionsFromLog(entries) {
|
|
77
|
+
const byId = new Map();
|
|
78
|
+
const order = [];
|
|
79
|
+
const extraReq = new Map();
|
|
80
|
+
const extraRes = new Map();
|
|
81
|
+
const ms = (from, to) => (from == null || to == null ? undefined : Math.round((to - from) * 1000));
|
|
82
|
+
const setResponse = (c, r) => {
|
|
83
|
+
c.status = Number(r.status || 0);
|
|
84
|
+
c.status_text = r.statusText || "";
|
|
85
|
+
c.mime_type = r.mimeType || "";
|
|
86
|
+
c.response_headers = { ...(r.headers || {}) };
|
|
87
|
+
};
|
|
88
|
+
for (const [method, p] of events(entries)) {
|
|
89
|
+
const id = p.requestId;
|
|
90
|
+
if (!id) continue;
|
|
91
|
+
if (method === "Network.requestWillBeSent") {
|
|
92
|
+
const prev = byId.get(id);
|
|
93
|
+
if (prev && p.redirectResponse) {
|
|
94
|
+
// la redirección cierra el salto anterior con su propia respuesta
|
|
95
|
+
setResponse(prev, p.redirectResponse);
|
|
96
|
+
prev.duration_ms = ms(prev._ts, p.timestamp);
|
|
97
|
+
prev._requestId = null;
|
|
98
|
+
}
|
|
99
|
+
const req = p.request || {};
|
|
100
|
+
const c = {
|
|
101
|
+
_requestId: id, _ts: p.timestamp, method: req.method || "", url: req.url || "",
|
|
102
|
+
resource_type: String(p.type || "").toLowerCase(), request_headers: { ...(req.headers || {}) },
|
|
103
|
+
post_data: req.postData || "", _hasPostData: Boolean(req.hasPostData || req.postData),
|
|
104
|
+
started_at: p.wallTime ? Math.round(p.wallTime * 1000) : 0, status: 0,
|
|
105
|
+
};
|
|
106
|
+
byId.set(id, c);
|
|
107
|
+
order.push(c);
|
|
108
|
+
} else if (method === "Network.requestWillBeSentExtraInfo") {
|
|
109
|
+
extraReq.set(id, { ...(extraReq.get(id) || {}), ...(p.headers || {}) });
|
|
110
|
+
} else if (method === "Network.responseReceived" && byId.has(id)) {
|
|
111
|
+
const c = byId.get(id);
|
|
112
|
+
setResponse(c, p.response || {});
|
|
113
|
+
if (p.type) c.resource_type = String(p.type).toLowerCase();
|
|
114
|
+
} else if (method === "Network.responseReceivedExtraInfo") {
|
|
115
|
+
extraRes.set(id, { ...(extraRes.get(id) || {}), ...(p.headers || {}) });
|
|
116
|
+
} else if (method === "Network.loadingFinished" && byId.has(id)) {
|
|
117
|
+
const c = byId.get(id);
|
|
118
|
+
c.duration_ms = ms(c._ts, p.timestamp);
|
|
119
|
+
} else if (method === "Network.loadingFailed" && byId.has(id)) {
|
|
120
|
+
const c = byId.get(id);
|
|
121
|
+
c.failed = true;
|
|
122
|
+
c.error_text = p.errorText || (p.canceled ? "canceled" : "failed");
|
|
123
|
+
c.duration_ms = ms(c._ts, p.timestamp);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const c of order) {
|
|
128
|
+
if (/^(data|blob):/.test(c.url)) continue;
|
|
129
|
+
// los headers reales que salieron, con la cookie de sesión
|
|
130
|
+
if (extraReq.has(c._requestId)) c.request_headers = { ...c.request_headers, ...extraReq.get(c._requestId) };
|
|
131
|
+
if (extraRes.has(c._requestId) && c.response_headers) c.response_headers = { ...c.response_headers, ...extraRes.get(c._requestId) };
|
|
132
|
+
delete c._ts;
|
|
133
|
+
out.push(c);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function cdp(driver, cmd, params) {
|
|
139
|
+
try {
|
|
140
|
+
return (await driver.sendAndGetDevToolsCommand(cmd, params)) || {};
|
|
141
|
+
} catch {
|
|
142
|
+
return {}; // otro navegador, o el recurso ya no existe (la página navegó)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function wantsBody(c) {
|
|
147
|
+
if (c.failed || !c._requestId) return false;
|
|
148
|
+
if (!BODY_TYPES.has(c.resource_type) && c.status < 400) return false;
|
|
149
|
+
const mime = String(c.mime_type || "").toLowerCase();
|
|
150
|
+
return !mime || TEXT_MIME.some((t) => mime.includes(t));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readLog(driver, kind) {
|
|
154
|
+
try {
|
|
155
|
+
return await driver.manage().logs().get(kind);
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Las llamadas de red desde la última lectura (el log se vacía al leerlo: cada test lee las
|
|
163
|
+
* suyas), con los bodies de las APIs y de las respuestas con error. [] si el driver no tiene el
|
|
164
|
+
* log de rendimiento (ver `seleniumOptions`).
|
|
165
|
+
*/
|
|
166
|
+
export async function captureNetwork(driver) {
|
|
167
|
+
const conns = connectionsFromLog(await readLog(driver, "performance"));
|
|
168
|
+
for (const c of conns) {
|
|
169
|
+
if (c._hasPostData && !c.post_data && c._requestId) {
|
|
170
|
+
c.post_data = (await cdp(driver, "Network.getRequestPostData", { requestId: c._requestId })).postData || "";
|
|
171
|
+
}
|
|
172
|
+
if (wantsBody(c)) {
|
|
173
|
+
const res = await cdp(driver, "Network.getResponseBody", { requestId: c._requestId });
|
|
174
|
+
if (typeof res.body === "string") {
|
|
175
|
+
const body = res.base64Encoded ? Buffer.from(res.body, "base64").toString("utf8") : res.body;
|
|
176
|
+
c.body_size = Buffer.byteLength(body);
|
|
177
|
+
c.response_body = body;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
delete c._requestId;
|
|
181
|
+
delete c._hasPostData;
|
|
182
|
+
}
|
|
183
|
+
return conns;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Errores y warnings de la consola desde la última lectura (el formato de `t.console`). */
|
|
187
|
+
export async function captureConsole(driver) {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const e of await readLog(driver, "browser")) {
|
|
190
|
+
const name = String(e?.level?.name ?? e?.level ?? "").toUpperCase();
|
|
191
|
+
let level = name === "SEVERE" ? "error" : name === "WARNING" ? "warning" : "";
|
|
192
|
+
if (!level) continue;
|
|
193
|
+
if (level === "error" && /\bUncaught\b/.test(e.message || "")) level = "pageerror";
|
|
194
|
+
out.push({ level, text: String(e.message || ""), location: "", timestamp: Number(e.timestamp || 0) });
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Vacía los logs del driver: lo de antes del test (otro test con el mismo driver) no se envía. */
|
|
200
|
+
export async function discardLogs(driver) {
|
|
201
|
+
await readLog(driver, "performance");
|
|
202
|
+
await readLog(driver, "browser");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Envía al test la red y la consola desde la última lectura. Devuelve cuántas llamadas envió. */
|
|
206
|
+
export async function reportSelenium(t, driver) {
|
|
207
|
+
const conns = await captureNetwork(driver);
|
|
208
|
+
if (conns.length) t.network(conns);
|
|
209
|
+
const consoleEntries = await captureConsole(driver);
|
|
210
|
+
if (consoleEntries.length) t.console(consoleEntries);
|
|
211
|
+
return conns.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Opciones para `cr.test(...)` con un driver de Selenium: al empezar descarta los logs anteriores,
|
|
216
|
+
* al terminar envía la red y la consola del test, y si falla la captura y el DOM.
|
|
217
|
+
*/
|
|
40
218
|
export function withDriver(driver, opts = {}) {
|
|
41
219
|
return {
|
|
42
220
|
...opts,
|
|
221
|
+
async onStart(t) {
|
|
222
|
+
await discardLogs(driver);
|
|
223
|
+
if (opts.onStart) await opts.onStart(t);
|
|
224
|
+
},
|
|
43
225
|
async onFailure(t, err) {
|
|
44
226
|
t.screenshot(await screenshot(driver), "Captura al fallar", "FAIL");
|
|
45
227
|
t.dom(await captureDom(driver));
|
|
46
228
|
if (opts.onFailure) await opts.onFailure(t, err);
|
|
47
229
|
},
|
|
230
|
+
async onEnd(t, err) {
|
|
231
|
+
await reportSelenium(t, driver);
|
|
232
|
+
if (opts.onEnd) await opts.onEnd(t, err);
|
|
233
|
+
},
|
|
48
234
|
};
|
|
49
235
|
}
|