tracereports 0.2.0 → 0.4.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 +2 -1
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/index.d.ts +4 -0
- package/src/index.js +12 -6
- package/src/mask.js +107 -0
- package/src/mirror.js +4 -4
- package/src/offline.js +7 -6
- package/src/playwright.js +10 -6
- package/src/reporter.js +12 -3
- package/src/selenium.js +201 -13
package/README.es.md
CHANGED
|
@@ -12,7 +12,8 @@ npm install --save-dev tracereports
|
|
|
12
12
|
- **Playwright Test**: el reporter envía cada test con sus pasos, error y capturas, más los traces y
|
|
13
13
|
videos que guarda Playwright; con los fixtures, también la red que vio el navegador, el DOM al
|
|
14
14
|
fallar y la consola del navegador.
|
|
15
|
-
- **Selenium WebDriver o cualquier runner**:
|
|
15
|
+
- **Selenium WebDriver o cualquier runner**: con `seleniumOptions(options)` y `withDriver`, la red (pestaña
|
|
16
|
+
Red, Lab) y la consola de cada test, y la captura y el DOM al fallar ([guía de Selenium](https://github.com/josemiguellopez/tracereports/blob/main/docs/es/selenium.md)).
|
|
16
17
|
- **Nunca frena ni rompe tus tests**: la evidencia sale en segundo plano, con reintentos y sin
|
|
17
18
|
duplicados; los shards pueden unirse a la misma ejecución.
|
|
18
19
|
- **Sin servidor**: si el servidor está caído o rechaza el token, la ejecución se graba en local;
|
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ npm install --save-dev tracereports
|
|
|
12
12
|
- **Playwright Test**: the reporter sends each test with its steps, error and screenshots, plus the
|
|
13
13
|
traces and videos Playwright keeps; with the fixtures, also the network the browser saw, the DOM
|
|
14
14
|
at the failure and the browser console.
|
|
15
|
-
- **Selenium WebDriver or any runner**:
|
|
15
|
+
- **Selenium WebDriver or any runner**: with `seleniumOptions(options)` and `withDriver`, each test's network
|
|
16
|
+
(Network tab, Lab) and console, and the screenshot and DOM on failure ([Selenium guide](https://github.com/josemiguellopez/tracereports/blob/main/docs/en/selenium.md)).
|
|
16
17
|
- **Never slows down or breaks your tests**: evidence goes out in the background with retries and
|
|
17
18
|
no duplicates; shards can join the same run.
|
|
18
19
|
- **Without a server**: if the server is down or rejects the token, the run is recorded locally;
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export interface RunOptions { environment?: string; project?: string; branch?: s
|
|
|
26
26
|
export interface TestOptions {
|
|
27
27
|
category?: string; description?: string; key?: string; suite?: string; params?: string; worker?: string | number;
|
|
28
28
|
status?: string; onFailure?: (t: TraceTest, err: unknown) => unknown | Promise<unknown>;
|
|
29
|
+
/** Right after the test starts (e.g. discard the browser logs of earlier tests). */
|
|
30
|
+
onStart?: (t: TraceTest) => unknown | Promise<unknown>;
|
|
31
|
+
/** Before the test is closed, passed or failed (`err` is null when it passed): network, console... */
|
|
32
|
+
onEnd?: (t: TraceTest, err: unknown) => unknown | Promise<unknown>;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export type Status = "INFO" | "PASS" | "FAIL" | "WARNING" | "SKIP";
|
package/src/index.js
CHANGED
|
@@ -22,12 +22,13 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { detectBranch, detectCommit } from "./context.js";
|
|
24
24
|
import { env } from "./env.js";
|
|
25
|
+
import { screenshotAllowed } from "./mask.js";
|
|
25
26
|
import { Recorder, newSessionDir } from "./offline.js";
|
|
26
27
|
import { Mirror } from "./mirror.js";
|
|
27
28
|
import { Sender } from "./transport.js";
|
|
28
29
|
import { downloadBinary } from "./binary.js";
|
|
29
30
|
|
|
30
|
-
export const VERSION = "0.
|
|
31
|
+
export const VERSION = "0.4.0";
|
|
31
32
|
const STATUSES = new Set(["INFO", "PASS", "FAIL", "WARNING", "SKIP"]);
|
|
32
33
|
const truthy = (v) => ["1", "true", "yes"].includes(String(v || "").toLowerCase());
|
|
33
34
|
|
|
@@ -314,19 +315,23 @@ export class TraceReports {
|
|
|
314
315
|
|
|
315
316
|
/**
|
|
316
317
|
* Corre `fn` como un test: lo inicia, lo cierra según el resultado y, si falla, adjunta la
|
|
317
|
-
* captura y el snapshot de `onFailure` (opcional).
|
|
318
|
+
* captura y el snapshot de `onFailure` (opcional). `onStart` corre al empezar y `onEnd` antes de
|
|
319
|
+
* cerrarlo, pase o falle (la red y la consola de `withDriver`). El error se vuelve a lanzar.
|
|
318
320
|
*/
|
|
319
321
|
async test(name, opts, fn) {
|
|
320
322
|
if (typeof opts === "function") [fn, opts] = [opts, {}];
|
|
321
323
|
const t = await this.startTest(name, opts);
|
|
324
|
+
// la evidencia nunca rompe el test
|
|
325
|
+
const hook = async (h, ...args) => { if (h) { try { await h(t, ...args); } catch { /* sin evidencia */ } } };
|
|
326
|
+
await hook(opts.onStart);
|
|
322
327
|
try {
|
|
323
328
|
const out = await fn(t);
|
|
329
|
+
await hook(opts.onEnd, null);
|
|
324
330
|
t.finish(opts.status);
|
|
325
331
|
return out;
|
|
326
332
|
} catch (err) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
333
|
+
await hook(opts.onFailure, err);
|
|
334
|
+
await hook(opts.onEnd, err);
|
|
330
335
|
t.fail(`${err?.name || "Error"}: ${err?.message ?? err}`);
|
|
331
336
|
t.finish("FAIL", { error: err });
|
|
332
337
|
throw err;
|
|
@@ -365,7 +370,8 @@ export class TraceTest {
|
|
|
365
370
|
* base64 (Selenium `driver.takeScreenshot()`).
|
|
366
371
|
*/
|
|
367
372
|
screenshot(image, message = "", status = "INFO") {
|
|
368
|
-
|
|
373
|
+
// TRACEREPORTS_SCREENSHOTS=off|failures: la captura no se envía (ver mask.js)
|
|
374
|
+
if (!this.active || !image || !screenshotAllowed(status)) return;
|
|
369
375
|
const data = typeof image === "string" ? Buffer.from(image, "base64") : Buffer.from(image);
|
|
370
376
|
const boundary = `----tracereports${Math.random().toString(16).slice(2)}`;
|
|
371
377
|
const part = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
package/src/mask.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Capturas sin datos sensibles: se tapan antes de capturar.
|
|
2
|
+
//
|
|
3
|
+
// - TRACEREPORTS_SCREENSHOT_MASK: selectores CSS a tapar (un grupo CSS: "input[name=rut], .tarjeta").
|
|
4
|
+
// - Siempre se tapan input[type=password] y los elementos con el atributo data-tracereports-mask;
|
|
5
|
+
// TRACEREPORTS_SCREENSHOT_MASK_DEFAULTS=off lo desactiva.
|
|
6
|
+
// - TRACEREPORTS_SCREENSHOTS: all (por defecto), failures (solo la captura de un paso FAIL) u off
|
|
7
|
+
// (ninguna). El servidor aplica la misma política por su cuenta.
|
|
8
|
+
//
|
|
9
|
+
// Playwright usa su opción nativa `mask`. Selenium no la tiene: se ponen recuadros del mismo color
|
|
10
|
+
// sobre los elementos, se captura y se quitan, también si la captura falla, así el DOM queda igual.
|
|
11
|
+
// Sin dependencias: lo importan index.js, playwright.js y selenium.js.
|
|
12
|
+
|
|
13
|
+
import { env } from "./env.js";
|
|
14
|
+
|
|
15
|
+
/** Lo que se tapa siempre (salvo TRACEREPORTS_SCREENSHOT_MASK_DEFAULTS=off). */
|
|
16
|
+
export const DEFAULT_MASK = "input[type=password], [data-tracereports-mask]";
|
|
17
|
+
/** Color de los recuadros: el mismo que usa Playwright por defecto. */
|
|
18
|
+
export const MASK_COLOR = "#FF00FF";
|
|
19
|
+
|
|
20
|
+
const OVERLAY_ATTR = "data-tracereports-mask-overlay";
|
|
21
|
+
|
|
22
|
+
/** Nombre del attachment de Playwright con la captura al fallar ya tapada (la toman los fixtures). */
|
|
23
|
+
export const MASKED_SHOT = "tracereports-screenshot";
|
|
24
|
+
|
|
25
|
+
/** El grupo de selectores CSS a tapar ("" = nada). */
|
|
26
|
+
export function maskSelector() {
|
|
27
|
+
const parts = [];
|
|
28
|
+
if (!["off", "0", "false", "no"].includes(String(env("SCREENSHOT_MASK_DEFAULTS") ?? "").trim().toLowerCase())) {
|
|
29
|
+
parts.push(DEFAULT_MASK);
|
|
30
|
+
}
|
|
31
|
+
const extra = String(env("SCREENSHOT_MASK") ?? "").trim().replace(/^,+|,+$/g, "").trim();
|
|
32
|
+
if (extra) parts.push(extra);
|
|
33
|
+
return parts.join(", ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** TRACEREPORTS_SCREENSHOTS: all, failures u off (un valor desconocido cuenta como all). */
|
|
37
|
+
export function screenshotsPolicy() {
|
|
38
|
+
const v = String(env("SCREENSHOTS") ?? "").trim().toLowerCase();
|
|
39
|
+
return v === "failures" || v === "off" ? v : "all";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Si la política deja enviar una captura de un paso con este estado. */
|
|
43
|
+
export function screenshotAllowed(status) {
|
|
44
|
+
const policy = screenshotsPolicy();
|
|
45
|
+
if (policy === "off") return false;
|
|
46
|
+
return policy === "all" || String(status).toUpperCase() === "FAIL";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `page.screenshot()` de Playwright con los elementos sensibles tapados (opción `mask`). */
|
|
50
|
+
export function playwrightScreenshot(page, options = {}) {
|
|
51
|
+
const selector = maskSelector();
|
|
52
|
+
const opts = { ...options };
|
|
53
|
+
if (selector) {
|
|
54
|
+
opts.mask ??= [page.locator(selector)];
|
|
55
|
+
opts.maskColor ??= MASK_COLOR;
|
|
56
|
+
}
|
|
57
|
+
return page.screenshot(opts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Recuadros fijos sobre cada elemento visible del selector (la captura de Selenium es del viewport).
|
|
61
|
+
const COVER_JS = `
|
|
62
|
+
const [sel, color, attr] = arguments;
|
|
63
|
+
let n = 0;
|
|
64
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
65
|
+
const r = el.getBoundingClientRect();
|
|
66
|
+
if (!r.width || !r.height) continue;
|
|
67
|
+
const d = document.createElement("div");
|
|
68
|
+
d.setAttribute(attr, "");
|
|
69
|
+
d.style.cssText = "position:fixed;left:" + r.left + "px;top:" + r.top + "px;width:" + r.width + "px;height:" + r.height + "px;" +
|
|
70
|
+
"background:" + color + ";z-index:2147483647;pointer-events:none;margin:0;padding:0;border:0";
|
|
71
|
+
document.documentElement.appendChild(d);
|
|
72
|
+
n++;
|
|
73
|
+
}
|
|
74
|
+
return n;
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const UNCOVER_JS = `for (const d of document.querySelectorAll("[" + arguments[0] + "]")) d.remove();`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Captura de Selenium (base64, lo que acepta `t.screenshot`) con los elementos sensibles tapados, o
|
|
81
|
+
* null si el navegador ya no responde. Los recuadros se quitan siempre; si no se pueden poner (un
|
|
82
|
+
* selector inválido) no se captura: mejor sin captura que sin máscara.
|
|
83
|
+
*/
|
|
84
|
+
export async function seleniumScreenshot(driver) {
|
|
85
|
+
const selector = maskSelector();
|
|
86
|
+
let covered = false;
|
|
87
|
+
try {
|
|
88
|
+
if (selector) {
|
|
89
|
+
try {
|
|
90
|
+
await driver.executeScript(COVER_JS, selector, MASK_COLOR, OVERLAY_ATTR);
|
|
91
|
+
covered = true;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(`tracereports: no se pudo tapar ${JSON.stringify(selector)} antes de la captura: ${err?.message ?? err}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return await driver.takeScreenshot();
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
} finally {
|
|
101
|
+
if (covered) {
|
|
102
|
+
try {
|
|
103
|
+
await driver.executeScript(UNCOVER_JS, OVERLAY_ATTR);
|
|
104
|
+
} catch { /* el navegador ya no responde */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/mirror.js
CHANGED
|
@@ -17,7 +17,7 @@ export class Mirror {
|
|
|
17
17
|
this.closed = false;
|
|
18
18
|
this.disabled = false;
|
|
19
19
|
this.active = 0;
|
|
20
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
20
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
21
21
|
this.locked(() => {
|
|
22
22
|
const marker = path.join(directory, MARKER);
|
|
23
23
|
if (fs.existsSync(marker) && JSON.parse(fs.readFileSync(marker)).raw_removed) throw new Error("la carpeta solo contiene el reporte; usa otra carpeta");
|
|
@@ -33,7 +33,7 @@ export class Mirror {
|
|
|
33
33
|
const mapping = path.join(directory, "ids", `server-${runId}`);
|
|
34
34
|
if (payload) {
|
|
35
35
|
local = this.recorder.record("POST", "/api/v1/runs", JSON.stringify(payload), "application/json").run_id;
|
|
36
|
-
fs.writeFileSync(mapping, String(local), { flag: "wx" });
|
|
36
|
+
fs.writeFileSync(mapping, String(local), { flag: "wx", mode: 0o600 });
|
|
37
37
|
m.mirror.runs.push({ local, server: runId });
|
|
38
38
|
this.write(MARKER, m);
|
|
39
39
|
} else {
|
|
@@ -52,7 +52,7 @@ export class Mirror {
|
|
|
52
52
|
locked(fn) {
|
|
53
53
|
const lock = path.join(this.directory, ".mirror-lock");
|
|
54
54
|
for (let n = 0; ; n++) {
|
|
55
|
-
try { fs.mkdirSync(lock); break; } catch (err) {
|
|
55
|
+
try { fs.mkdirSync(lock, { mode: 0o700 }); break; } catch (err) {
|
|
56
56
|
if (err.code !== "EEXIST") throw err;
|
|
57
57
|
if (n >= 50) {
|
|
58
58
|
let stale = false;
|
|
@@ -70,7 +70,7 @@ export class Mirror {
|
|
|
70
70
|
write(name, value) {
|
|
71
71
|
const dest = path.join(this.directory, name), tmp = `${dest}.${randomUUID()}.tmp`;
|
|
72
72
|
try {
|
|
73
|
-
fs.writeFileSync(tmp, JSON.stringify(value));
|
|
73
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
74
74
|
try { fs.renameSync(tmp, dest); }
|
|
75
75
|
catch (err) {
|
|
76
76
|
// Windows can reject a replacement rename even while this process owns the lock.
|
package/src/offline.js
CHANGED
|
@@ -42,10 +42,11 @@ export class Recorder {
|
|
|
42
42
|
this.nextId = 0;
|
|
43
43
|
this.block = 0;
|
|
44
44
|
this.tag = randomUUID().slice(0, 8);
|
|
45
|
-
|
|
45
|
+
// la grabación lleva la evidencia sin redactar (eso lo hace el servidor): solo para el usuario
|
|
46
|
+
fs.mkdirSync(path.join(directory, "bodies"), { recursive: true, mode: 0o700 });
|
|
46
47
|
try {
|
|
47
48
|
fs.writeFileSync(path.join(directory, MARKER),
|
|
48
|
-
JSON.stringify({ format: "tracereports-offline", version: FORMAT_VERSION, id: randomUUID().replace(/-/g, "") }), { flag: "wx" });
|
|
49
|
+
JSON.stringify({ format: "tracereports-offline", version: FORMAT_VERSION, id: randomUUID().replace(/-/g, "") }), { flag: "wx", mode: 0o600 });
|
|
49
50
|
} catch (err) {
|
|
50
51
|
if (err.code !== "EEXIST") throw err; // otro proceso ya lo creó
|
|
51
52
|
}
|
|
@@ -64,11 +65,11 @@ export class Recorder {
|
|
|
64
65
|
|
|
65
66
|
reserveBlock() {
|
|
66
67
|
const ids = path.join(this.directory, "ids");
|
|
67
|
-
fs.mkdirSync(ids, { recursive: true });
|
|
68
|
+
fs.mkdirSync(ids, { recursive: true, mode: 0o700 });
|
|
68
69
|
for (let i = 0; i < 100; i++) {
|
|
69
70
|
const block = randomInt(MIN_BLOCK, MAX_BLOCK);
|
|
70
71
|
try {
|
|
71
|
-
fs.writeFileSync(path.join(ids, String(block)), "", { flag: "wx" });
|
|
72
|
+
fs.writeFileSync(path.join(ids, String(block)), "", { flag: "wx", mode: 0o600 });
|
|
72
73
|
return block;
|
|
73
74
|
} catch (err) {
|
|
74
75
|
if (err.code !== "EEXIST") throw err; // ya es de otro grabador: se prueba otro
|
|
@@ -91,7 +92,7 @@ export class Recorder {
|
|
|
91
92
|
if (parsed !== undefined) event.body = parsed;
|
|
92
93
|
else {
|
|
93
94
|
const name = `bodies/${process.pid}-${this.tag}-${this.seq}.bin`;
|
|
94
|
-
fs.writeFileSync(path.join(this.directory, name), body ?? "");
|
|
95
|
+
fs.writeFileSync(path.join(this.directory, name), body ?? "", { mode: 0o600 });
|
|
95
96
|
event.body_file = name;
|
|
96
97
|
}
|
|
97
98
|
let res = {};
|
|
@@ -99,7 +100,7 @@ export class Recorder {
|
|
|
99
100
|
else if (method === "POST" && apiPath.startsWith("/api/v1/runs/") && apiPath.endsWith("/tests")) res = { test_id: this.localId() };
|
|
100
101
|
const id = res.run_id ?? res.test_id;
|
|
101
102
|
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
|
+
fs.appendFileSync(this.file, JSON.stringify(event) + "\n", { mode: 0o600 }); // síncrono: si el proceso muere, lo escrito queda
|
|
103
104
|
this.stats.recorded++;
|
|
104
105
|
return res;
|
|
105
106
|
}
|
package/src/playwright.js
CHANGED
|
@@ -14,14 +14,18 @@
|
|
|
14
14
|
// navegador, no los logs del backend);
|
|
15
15
|
// - captura la consola: console.error, console.warn y los errores de JavaScript sin manejar
|
|
16
16
|
// (pestaña "Consola");
|
|
17
|
-
// - si el test falla: snapshot del DOM (recomendador de locators) y una captura
|
|
18
|
-
//
|
|
17
|
+
// - si el test falla: snapshot del DOM (recomendador de locators) y una captura con los datos
|
|
18
|
+
// sensibles tapados (ver mask.js); el reporter la sube en vez de la de `screenshot:
|
|
19
|
+
// "only-on-failure"`, que Playwright toma sin máscara.
|
|
19
20
|
// Todo viaja como attachments del test; el reporter `tracereports/reporter` los sube.
|
|
20
21
|
|
|
21
22
|
import { createRequire } from "node:module";
|
|
22
23
|
import path from "node:path";
|
|
23
24
|
import { pathToFileURL } from "node:url";
|
|
24
25
|
import { domSnapshotInPage } from "./dom.js";
|
|
26
|
+
import { MASKED_SHOT, playwrightScreenshot, screenshotsPolicy } from "./mask.js";
|
|
27
|
+
|
|
28
|
+
export { MASKED_SHOT, playwrightScreenshot };
|
|
25
29
|
|
|
26
30
|
// @playwright/test se carga desde el proyecto que corre los tests: así hay una sola copia aunque
|
|
27
31
|
// este paquete esté instalado como enlace (npm install ../tracereports/client/js).
|
|
@@ -169,11 +173,11 @@ const fixtures = {
|
|
|
169
173
|
if (testInfo.status !== testInfo.expectedStatus) {
|
|
170
174
|
const snap = await captureDom(page);
|
|
171
175
|
if (snap) await testInfo.attach("tracereports-dom", { body: JSON.stringify(snap), contentType: "application/json" });
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
176
|
+
// siempre la captura tapada: la automática de Playwright (screenshot: "only-on-failure") no se
|
|
177
|
+
// puede tapar, y el reporter la reemplaza por esta
|
|
178
|
+
if (screenshotsPolicy() !== "off") {
|
|
175
179
|
try {
|
|
176
|
-
await testInfo.attach(
|
|
180
|
+
await testInfo.attach(MASKED_SHOT, { body: await playwrightScreenshot(page, { timeout: 5000 }), contentType: "image/png" });
|
|
177
181
|
} catch { /* la página puede estar cerrada */ }
|
|
178
182
|
}
|
|
179
183
|
}
|
package/src/reporter.js
CHANGED
|
@@ -23,6 +23,7 @@ import fs from "node:fs";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { env } from "./env.js";
|
|
25
25
|
import { TraceReports } from "./index.js";
|
|
26
|
+
import { MASKED_SHOT, screenshotAllowed, screenshotsPolicy } from "./mask.js";
|
|
26
27
|
|
|
27
28
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
28
29
|
const truthy = (v) => ["1", "true", "yes"].includes(String(v || "").toLowerCase());
|
|
@@ -131,17 +132,25 @@ export default class TraceReportsReporter {
|
|
|
131
132
|
}));
|
|
132
133
|
for (const l of [...steps, ...logs].sort((a, b) => a.timestamp - b.timestamp)) ct.log(l.status, l.message, l.timestamp);
|
|
133
134
|
|
|
135
|
+
// la captura automática de Playwright no viene tapada: si los fixtures dejaron la suya, va esa
|
|
136
|
+
const masked = result.attachments.some((a) => a.name === MASKED_SHOT);
|
|
137
|
+
// trace y video muestran la pantalla: siguen TRACEREPORTS_SCREENSHOTS
|
|
138
|
+
const policy = screenshotsPolicy();
|
|
139
|
+
const artifacts = policy === "all" || (policy === "failures" && unexpected);
|
|
134
140
|
for (const att of result.attachments) {
|
|
141
|
+
if (att.name === "screenshot" && masked) continue;
|
|
142
|
+
const isImage = att.contentType?.startsWith("image/");
|
|
143
|
+
if (isImage && !screenshotAllowed(unexpected ? "FAIL" : "INFO")) continue;
|
|
135
144
|
const body = att.body ?? (att.path && fs.existsSync(att.path) ? fs.readFileSync(att.path) : null);
|
|
136
145
|
if (!body) continue;
|
|
137
146
|
if (att.name === "tracereports-network") ct.network(JSON.parse(body.toString()));
|
|
138
147
|
else if (att.name === "tracereports-dom") ct.dom(JSON.parse(body.toString()));
|
|
139
148
|
else if (att.name === "tracereports-console") ct.console(JSON.parse(body.toString()));
|
|
140
|
-
else if (
|
|
149
|
+
else if (isImage) ct.screenshot(body, att.name === "screenshot" || att.name === MASKED_SHOT ? "Captura al fallar" : att.name,
|
|
141
150
|
unexpected ? "FAIL" : "INFO");
|
|
142
151
|
// 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);
|
|
152
|
+
else if (att.name === "trace" && att.contentType === "application/zip") { if (artifacts) ct.artifact("trace", body, `trace-${result.retry + 1}.zip`); }
|
|
153
|
+
else if (att.contentType?.startsWith("video/")) { if (artifacts) ct.artifact("video", body, att.name === "video" ? `video-${result.retry + 1}` : att.name); }
|
|
145
154
|
}
|
|
146
155
|
const final = asExpected || skipped || result.retry >= test.retries;
|
|
147
156
|
if (!final) {
|
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,21 +18,38 @@
|
|
|
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";
|
|
28
|
+
import { seleniumScreenshot } from "./mask.js";
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
const LOG_PREFS = { performance: "ALL", browser: "ALL" };
|
|
31
|
+
const BODY_TYPES = new Set(["xhr", "fetch"]);
|
|
32
|
+
const TEXT_MIME = ["json", "text", "xml", "javascript", "html", "x-www-form-urlencoded", "graphql"];
|
|
33
|
+
|
|
34
|
+
/** Activa en las opciones de Chrome o Edge los logs de rendimiento (red) y del navegador (consola). */
|
|
35
|
+
export function seleniumOptions(options) {
|
|
36
|
+
options.set("goog:loggingPrefs", { ...LOG_PREFS });
|
|
37
|
+
if (/edge/i.test(options?.constructor?.name || "") || options.get?.("browserName") === "MicrosoftEdge") {
|
|
38
|
+
options.set("ms:loggingPrefs", { ...LOG_PREFS });
|
|
27
39
|
}
|
|
40
|
+
return options;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Captura de pantalla como base64 (lo que acepta `t.screenshot`) con los datos sensibles tapados
|
|
45
|
+
* (ver mask.js), o null.
|
|
46
|
+
*/
|
|
47
|
+
export async function screenshot(driver) {
|
|
48
|
+
return seleniumScreenshot(driver);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { seleniumScreenshot };
|
|
52
|
+
|
|
30
53
|
/** Snapshot de los elementos de la página, o null si el navegador ya no responde. */
|
|
31
54
|
export async function captureDom(driver) {
|
|
32
55
|
try {
|
|
@@ -36,14 +59,179 @@ export async function captureDom(driver) {
|
|
|
36
59
|
}
|
|
37
60
|
}
|
|
38
61
|
|
|
39
|
-
|
|
62
|
+
function* events(entries) {
|
|
63
|
+
for (const entry of entries || []) {
|
|
64
|
+
let msg;
|
|
65
|
+
try {
|
|
66
|
+
msg = JSON.parse(entry.message).message;
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (msg?.method?.startsWith("Network.")) yield [msg.method, msg.params || {}];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convierte las entradas del log "performance" (`driver.manage().logs().get("performance")`) en
|
|
76
|
+
* conexiones (el formato de `t.network`). Cada una lleva `_requestId` para pedir su body después.
|
|
77
|
+
*/
|
|
78
|
+
export function connectionsFromLog(entries) {
|
|
79
|
+
const byId = new Map();
|
|
80
|
+
const order = [];
|
|
81
|
+
const extraReq = new Map();
|
|
82
|
+
const extraRes = new Map();
|
|
83
|
+
const ms = (from, to) => (from == null || to == null ? undefined : Math.round((to - from) * 1000));
|
|
84
|
+
const setResponse = (c, r) => {
|
|
85
|
+
c.status = Number(r.status || 0);
|
|
86
|
+
c.status_text = r.statusText || "";
|
|
87
|
+
c.mime_type = r.mimeType || "";
|
|
88
|
+
c.response_headers = { ...(r.headers || {}) };
|
|
89
|
+
};
|
|
90
|
+
for (const [method, p] of events(entries)) {
|
|
91
|
+
const id = p.requestId;
|
|
92
|
+
if (!id) continue;
|
|
93
|
+
if (method === "Network.requestWillBeSent") {
|
|
94
|
+
const prev = byId.get(id);
|
|
95
|
+
if (prev && p.redirectResponse) {
|
|
96
|
+
// la redirección cierra el salto anterior con su propia respuesta
|
|
97
|
+
setResponse(prev, p.redirectResponse);
|
|
98
|
+
prev.duration_ms = ms(prev._ts, p.timestamp);
|
|
99
|
+
prev._requestId = null;
|
|
100
|
+
}
|
|
101
|
+
const req = p.request || {};
|
|
102
|
+
const c = {
|
|
103
|
+
_requestId: id, _ts: p.timestamp, method: req.method || "", url: req.url || "",
|
|
104
|
+
resource_type: String(p.type || "").toLowerCase(), request_headers: { ...(req.headers || {}) },
|
|
105
|
+
post_data: req.postData || "", _hasPostData: Boolean(req.hasPostData || req.postData),
|
|
106
|
+
started_at: p.wallTime ? Math.round(p.wallTime * 1000) : 0, status: 0,
|
|
107
|
+
};
|
|
108
|
+
byId.set(id, c);
|
|
109
|
+
order.push(c);
|
|
110
|
+
} else if (method === "Network.requestWillBeSentExtraInfo") {
|
|
111
|
+
extraReq.set(id, { ...(extraReq.get(id) || {}), ...(p.headers || {}) });
|
|
112
|
+
} else if (method === "Network.responseReceived" && byId.has(id)) {
|
|
113
|
+
const c = byId.get(id);
|
|
114
|
+
setResponse(c, p.response || {});
|
|
115
|
+
if (p.type) c.resource_type = String(p.type).toLowerCase();
|
|
116
|
+
} else if (method === "Network.responseReceivedExtraInfo") {
|
|
117
|
+
extraRes.set(id, { ...(extraRes.get(id) || {}), ...(p.headers || {}) });
|
|
118
|
+
} else if (method === "Network.loadingFinished" && byId.has(id)) {
|
|
119
|
+
const c = byId.get(id);
|
|
120
|
+
c.duration_ms = ms(c._ts, p.timestamp);
|
|
121
|
+
} else if (method === "Network.loadingFailed" && byId.has(id)) {
|
|
122
|
+
const c = byId.get(id);
|
|
123
|
+
c.failed = true;
|
|
124
|
+
c.error_text = p.errorText || (p.canceled ? "canceled" : "failed");
|
|
125
|
+
c.duration_ms = ms(c._ts, p.timestamp);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const c of order) {
|
|
130
|
+
if (/^(data|blob):/.test(c.url)) continue;
|
|
131
|
+
// los headers reales que salieron, con la cookie de sesión
|
|
132
|
+
if (extraReq.has(c._requestId)) c.request_headers = { ...c.request_headers, ...extraReq.get(c._requestId) };
|
|
133
|
+
if (extraRes.has(c._requestId) && c.response_headers) c.response_headers = { ...c.response_headers, ...extraRes.get(c._requestId) };
|
|
134
|
+
delete c._ts;
|
|
135
|
+
out.push(c);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function cdp(driver, cmd, params) {
|
|
141
|
+
try {
|
|
142
|
+
return (await driver.sendAndGetDevToolsCommand(cmd, params)) || {};
|
|
143
|
+
} catch {
|
|
144
|
+
return {}; // otro navegador, o el recurso ya no existe (la página navegó)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function wantsBody(c) {
|
|
149
|
+
if (c.failed || !c._requestId) return false;
|
|
150
|
+
if (!BODY_TYPES.has(c.resource_type) && c.status < 400) return false;
|
|
151
|
+
const mime = String(c.mime_type || "").toLowerCase();
|
|
152
|
+
return !mime || TEXT_MIME.some((t) => mime.includes(t));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readLog(driver, kind) {
|
|
156
|
+
try {
|
|
157
|
+
return await driver.manage().logs().get(kind);
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Las llamadas de red desde la última lectura (el log se vacía al leerlo: cada test lee las
|
|
165
|
+
* suyas), con los bodies de las APIs y de las respuestas con error. [] si el driver no tiene el
|
|
166
|
+
* log de rendimiento (ver `seleniumOptions`).
|
|
167
|
+
*/
|
|
168
|
+
export async function captureNetwork(driver) {
|
|
169
|
+
const conns = connectionsFromLog(await readLog(driver, "performance"));
|
|
170
|
+
for (const c of conns) {
|
|
171
|
+
if (c._hasPostData && !c.post_data && c._requestId) {
|
|
172
|
+
c.post_data = (await cdp(driver, "Network.getRequestPostData", { requestId: c._requestId })).postData || "";
|
|
173
|
+
}
|
|
174
|
+
if (wantsBody(c)) {
|
|
175
|
+
const res = await cdp(driver, "Network.getResponseBody", { requestId: c._requestId });
|
|
176
|
+
if (typeof res.body === "string") {
|
|
177
|
+
const body = res.base64Encoded ? Buffer.from(res.body, "base64").toString("utf8") : res.body;
|
|
178
|
+
c.body_size = Buffer.byteLength(body);
|
|
179
|
+
c.response_body = body;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
delete c._requestId;
|
|
183
|
+
delete c._hasPostData;
|
|
184
|
+
}
|
|
185
|
+
return conns;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Errores y warnings de la consola desde la última lectura (el formato de `t.console`). */
|
|
189
|
+
export async function captureConsole(driver) {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const e of await readLog(driver, "browser")) {
|
|
192
|
+
const name = String(e?.level?.name ?? e?.level ?? "").toUpperCase();
|
|
193
|
+
let level = name === "SEVERE" ? "error" : name === "WARNING" ? "warning" : "";
|
|
194
|
+
if (!level) continue;
|
|
195
|
+
if (level === "error" && /\bUncaught\b/.test(e.message || "")) level = "pageerror";
|
|
196
|
+
out.push({ level, text: String(e.message || ""), location: "", timestamp: Number(e.timestamp || 0) });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Vacía los logs del driver: lo de antes del test (otro test con el mismo driver) no se envía. */
|
|
202
|
+
export async function discardLogs(driver) {
|
|
203
|
+
await readLog(driver, "performance");
|
|
204
|
+
await readLog(driver, "browser");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Envía al test la red y la consola desde la última lectura. Devuelve cuántas llamadas envió. */
|
|
208
|
+
export async function reportSelenium(t, driver) {
|
|
209
|
+
const conns = await captureNetwork(driver);
|
|
210
|
+
if (conns.length) t.network(conns);
|
|
211
|
+
const consoleEntries = await captureConsole(driver);
|
|
212
|
+
if (consoleEntries.length) t.console(consoleEntries);
|
|
213
|
+
return conns.length;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Opciones para `cr.test(...)` con un driver de Selenium: al empezar descarta los logs anteriores,
|
|
218
|
+
* al terminar envía la red y la consola del test, y si falla la captura y el DOM.
|
|
219
|
+
*/
|
|
40
220
|
export function withDriver(driver, opts = {}) {
|
|
41
221
|
return {
|
|
42
222
|
...opts,
|
|
223
|
+
async onStart(t) {
|
|
224
|
+
await discardLogs(driver);
|
|
225
|
+
if (opts.onStart) await opts.onStart(t);
|
|
226
|
+
},
|
|
43
227
|
async onFailure(t, err) {
|
|
44
228
|
t.screenshot(await screenshot(driver), "Captura al fallar", "FAIL");
|
|
45
229
|
t.dom(await captureDom(driver));
|
|
46
230
|
if (opts.onFailure) await opts.onFailure(t, err);
|
|
47
231
|
},
|
|
232
|
+
async onEnd(t, err) {
|
|
233
|
+
await reportSelenium(t, driver);
|
|
234
|
+
if (opts.onEnd) await opts.onEnd(t, err);
|
|
235
|
+
},
|
|
48
236
|
};
|
|
49
237
|
}
|