tracereports 0.3.0 → 0.4.1
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/package.json +1 -1
- package/src/index.js +13 -11
- package/src/mask.js +107 -0
- package/src/mirror.js +10 -10
- package/src/offline.js +7 -6
- package/src/playwright.js +10 -6
- package/src/reporter.js +20 -11
- package/src/selenium.js +8 -6
- package/src/transport.js +4 -4
package/package.json
CHANGED
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.1";
|
|
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
|
|
|
@@ -132,12 +133,12 @@ export class TraceReports {
|
|
|
132
133
|
try {
|
|
133
134
|
this.sender = new Recorder(dir);
|
|
134
135
|
} catch (err) {
|
|
135
|
-
console.warn(`tracereports:
|
|
136
|
+
console.warn(`tracereports: could not record locally in ${dir}: ${err.message}`);
|
|
136
137
|
return;
|
|
137
138
|
}
|
|
138
139
|
this.offlineDir = dir;
|
|
139
140
|
if (reason) {
|
|
140
|
-
console.warn(`tracereports: ${reason};
|
|
141
|
+
console.warn(`tracereports: ${reason}; recording the evidence in ${dir}`);
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
@@ -146,7 +147,7 @@ export class TraceReports {
|
|
|
146
147
|
try {
|
|
147
148
|
this.sender = new Mirror(this.sender, dir, this.baseUrl, runId, payload);
|
|
148
149
|
this.offlineDir = dir;
|
|
149
|
-
} catch (err) { console.warn(`tracereports:
|
|
150
|
+
} catch (err) { console.warn(`tracereports: could not start local copy in ${dir}: ${err.message}`); }
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/** Arma el HTML con el binario instalado o el renderer verificado de la caché. */
|
|
@@ -162,7 +163,7 @@ export class TraceReports {
|
|
|
162
163
|
if (!fs.statSync(index).isFile()) throw Error("renderer did not create index.html");
|
|
163
164
|
this.offlineReport = index;
|
|
164
165
|
if (this.runId > 0) console.warn(`tracereports: server report: ${this.reportUrl}`);
|
|
165
|
-
console.warn(`tracereports:
|
|
166
|
+
console.warn(`tracereports: local report in ${this.offlineReport}`);
|
|
166
167
|
return this.offlineReport;
|
|
167
168
|
} catch (err) {
|
|
168
169
|
console.warn(`tracereports: local report unavailable (${err.message}); evidence retained in ${this.offlineDir}. ` +
|
|
@@ -233,14 +234,14 @@ export class TraceReports {
|
|
|
233
234
|
let created = res;
|
|
234
235
|
if (!created && this.enabled && ["auto", "both"].includes(this.offlineMode) && !this.recording) {
|
|
235
236
|
// sin servidor, caído o con el token equivocado: se graba en vez de perderlo todo
|
|
236
|
-
this.goOffline(`
|
|
237
|
+
this.goOffline(`could not create the run at ${this.baseUrl} (server down or wrong token)`);
|
|
237
238
|
if (this.recording) created = await this.request("POST", "/api/v1/runs", payload);
|
|
238
239
|
}
|
|
239
240
|
if (created?.run_id > 0 && this.offlineMode === "both" && !this.recording) this.goMirror(created.run_id, payload);
|
|
240
241
|
this.runId = created?.run_id ?? null;
|
|
241
242
|
this.runCreated = Boolean(this.runId);
|
|
242
243
|
if (this.enabled && !this.runId) {
|
|
243
|
-
console.warn(`tracereports:
|
|
244
|
+
console.warn(`tracereports: could not create the run at ${this.baseUrl}; tests will continue without reporting.`);
|
|
244
245
|
}
|
|
245
246
|
return this.runId;
|
|
246
247
|
}
|
|
@@ -286,8 +287,8 @@ export class TraceReports {
|
|
|
286
287
|
return res;
|
|
287
288
|
}
|
|
288
289
|
if (this.runNotClosed) {
|
|
289
|
-
console.warn(`tracereports:
|
|
290
|
-
`(
|
|
290
|
+
console.warn(`tracereports: the server did not confirm that run #${this.runId} finished: it stays open ` +
|
|
291
|
+
`(finish it with PATCH ${this.baseUrl}/api/v1/runs/${this.runId}/finish)`);
|
|
291
292
|
}
|
|
292
293
|
return res;
|
|
293
294
|
}
|
|
@@ -369,7 +370,8 @@ export class TraceTest {
|
|
|
369
370
|
* base64 (Selenium `driver.takeScreenshot()`).
|
|
370
371
|
*/
|
|
371
372
|
screenshot(image, message = "", status = "INFO") {
|
|
372
|
-
|
|
373
|
+
// TRACEREPORTS_SCREENSHOTS=off|failures: la captura no se envía (ver mask.js)
|
|
374
|
+
if (!this.active || !image || !screenshotAllowed(status)) return;
|
|
373
375
|
const data = typeof image === "string" ? Buffer.from(image, "base64") : Buffer.from(image);
|
|
374
376
|
const boundary = `----tracereports${Math.random().toString(16).slice(2)}`;
|
|
375
377
|
const part = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
@@ -409,7 +411,7 @@ export class TraceTest {
|
|
|
409
411
|
}
|
|
410
412
|
}
|
|
411
413
|
if (body.length > 100 << 20) {
|
|
412
|
-
console.warn(`tracereports: ${name || kind}
|
|
414
|
+
console.warn(`tracereports: ${name || kind} is larger than 100 MB: not attached`);
|
|
413
415
|
return false;
|
|
414
416
|
}
|
|
415
417
|
const boundary = `----tracereports${Math.random().toString(16).slice(2)}`;
|
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: could not cover ${JSON.stringify(selector)} before the screenshot: ${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,15 +17,15 @@ 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
|
-
if (fs.existsSync(marker) && JSON.parse(fs.readFileSync(marker)).raw_removed) throw new Error("
|
|
23
|
+
if (fs.existsSync(marker) && JSON.parse(fs.readFileSync(marker)).raw_removed) throw new Error("the folder only contains the report; use another folder");
|
|
24
24
|
this.recorder = new Recorder(directory);
|
|
25
25
|
this.status = `status-${process.pid}-${this.recorder.tag}.json`;
|
|
26
26
|
this.write(this.status, { complete: false, reason: "active" });
|
|
27
27
|
const m = JSON.parse(fs.readFileSync(marker));
|
|
28
|
-
if (m.mirror && m.mirror.server !== server) throw new Error("
|
|
28
|
+
if (m.mirror && m.mirror.server !== server) throw new Error("the folder belongs to another server");
|
|
29
29
|
m.mirror ||= { server, runs: [], complete: false };
|
|
30
30
|
m.mirror.complete = false;
|
|
31
31
|
this.write(MARKER, m);
|
|
@@ -33,16 +33,16 @@ 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 {
|
|
40
40
|
try {
|
|
41
41
|
local = Number(fs.readFileSync(mapping, "utf8"));
|
|
42
|
-
if (!Number.isSafeInteger(local) || local >= 0) throw new Error("
|
|
42
|
+
if (!Number.isSafeInteger(local) || local >= 0) throw new Error("invalid local id");
|
|
43
43
|
} catch (err) {
|
|
44
44
|
this.failed = true;
|
|
45
|
-
console.warn(`tracereports:
|
|
45
|
+
console.warn(`tracereports: missing local id for run #${runId}: ${err.message}`);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
if (local) this.runs.set(runId, local);
|
|
@@ -52,12 +52,12 @@ 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;
|
|
59
59
|
try { stale = Date.now() - fs.statSync(lock).mtimeMs > STALE_LOCK_MS; } catch { /* ya no existe */ }
|
|
60
|
-
if (!stale) throw new Error(`${lock}
|
|
60
|
+
if (!stale) throw new Error(`${lock} is held by another process (delete it if no run uses this folder)`);
|
|
61
61
|
try { fs.rmdirSync(lock); } catch { /* otro proceso lo retiró antes */ }
|
|
62
62
|
n = 0; // huérfano: se retira y se vuelve a intentar
|
|
63
63
|
}
|
|
@@ -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.
|
|
@@ -83,7 +83,7 @@ export class Mirror {
|
|
|
83
83
|
|
|
84
84
|
diskError(err) {
|
|
85
85
|
this.failed = true;
|
|
86
|
-
if (!this.disabled) console.warn(`tracereports:
|
|
86
|
+
if (!this.disabled) console.warn(`tracereports: stopped local copy in ${this.directory}: ${err.message}`);
|
|
87
87
|
this.disabled = true;
|
|
88
88
|
}
|
|
89
89
|
|
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) {
|
|
@@ -172,18 +181,18 @@ export default class TraceReportsReporter {
|
|
|
172
181
|
const problems = this.cr.deliveryProblems() + (this.cr.recording && this.cr.runId < 0 && ["auto", "both"].includes(this.cr.offlineMode) ? 1 : 0);
|
|
173
182
|
if (this.cr.recording && this.cr.runId > 0) {
|
|
174
183
|
console.log(`TraceReports: ${this.cr.reportUrl}`);
|
|
175
|
-
console.log(`TraceReports
|
|
184
|
+
console.log(`TraceReports local copy: ${this.cr.offlineReport || `tracereports report ${this.cr.offlineDir}`}`);
|
|
176
185
|
} else if (this.cr.recording) {
|
|
177
|
-
console.log(`TraceReports (
|
|
178
|
-
(this.cr.reportUrl || `\`tracereports report ${this.cr.offlineDir} -o
|
|
179
|
-
`;
|
|
186
|
+
console.log(`TraceReports (no server): evidence in ${this.cr.offlineDir}; report: ` +
|
|
187
|
+
(this.cr.reportUrl || `\`tracereports report ${this.cr.offlineDir} -o report\``) +
|
|
188
|
+
`; to upload it: \`tracereports push ${this.cr.offlineDir}\``);
|
|
180
189
|
} else if (this.cr.runId) console.log(`TraceReports: ${this.cr.reportUrl}`);
|
|
181
190
|
if (problems && (!this.cr.recording || this.cr.runId > 0)) {
|
|
182
191
|
const d = this.cr.delivery;
|
|
183
|
-
console.warn(`TraceReports:
|
|
184
|
-
`(
|
|
185
|
-
(d.runNotClosed ? ";
|
|
186
|
-
(this.strict ? "
|
|
192
|
+
console.warn(`TraceReports: warning: ${problems - d.runNotClosed} evidence events did not reach the server ` +
|
|
193
|
+
`(sent ${d.sent}, rejected ${d.rejected}, dropped ${d.dropped}, lost ${d.lost})` +
|
|
194
|
+
(d.runNotClosed ? "; the server did not confirm that the run finished: it stays open" : "") +
|
|
195
|
+
(this.strict ? " - TRACEREPORTS_STRICT: the run fails" : ""));
|
|
187
196
|
}
|
|
188
197
|
if (this.strict && (problems || (this.cr.enabled && !this.cr.runId)) && result.status === "passed") {
|
|
189
198
|
return { status: "failed" };
|
package/src/selenium.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// página navegara puede no estar ya disponible; el resto de la llamada sí.
|
|
26
26
|
|
|
27
27
|
import { DOM_SCRIPT } from "./dom.js";
|
|
28
|
+
import { seleniumScreenshot } from "./mask.js";
|
|
28
29
|
|
|
29
30
|
const LOG_PREFS = { performance: "ALL", browser: "ALL" };
|
|
30
31
|
const BODY_TYPES = new Set(["xhr", "fetch"]);
|
|
@@ -39,15 +40,16 @@ export function seleniumOptions(options) {
|
|
|
39
40
|
return options;
|
|
40
41
|
}
|
|
41
42
|
|
|
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
|
+
*/
|
|
43
47
|
export async function screenshot(driver) {
|
|
44
|
-
|
|
45
|
-
return await driver.takeScreenshot();
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
48
|
+
return seleniumScreenshot(driver);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export { seleniumScreenshot };
|
|
52
|
+
|
|
51
53
|
/** Snapshot de los elementos de la página, o null si el navegador ya no responde. */
|
|
52
54
|
export async function captureDom(driver) {
|
|
53
55
|
try {
|
package/src/transport.js
CHANGED
|
@@ -60,11 +60,11 @@ export class Sender {
|
|
|
60
60
|
return { kind: "ok", data: text ? JSON.parse(text) : {} };
|
|
61
61
|
}
|
|
62
62
|
if (RETRYABLE.has(res.status)) return { kind: "retry" };
|
|
63
|
-
const hint = res.status === 401 ? " (
|
|
63
|
+
const hint = res.status === 401 ? " (set the token: new TraceReports({ token }), the TRACEREPORTS_TOKEN variable or TRACEREPORTS_TOKEN in the project .env)" : "";
|
|
64
64
|
console.warn(`tracereports: ${item.method} ${item.path} -> HTTP ${res.status} ${text.slice(0, 300)}${hint}`);
|
|
65
65
|
return { kind: "reject" };
|
|
66
66
|
} catch (err) {
|
|
67
|
-
if (!this.serverDown()) console.warn(`tracereports: ${item.method} ${item.path}
|
|
67
|
+
if (!this.serverDown()) console.warn(`tracereports: ${item.method} ${item.path} failed: ${err.cause?.code || err.message}`);
|
|
68
68
|
this.downUntil = Date.now() + limits.circuitMs;
|
|
69
69
|
return { kind: "retry" };
|
|
70
70
|
}
|
|
@@ -92,7 +92,7 @@ export class Sender {
|
|
|
92
92
|
const size = body?.length ?? 0;
|
|
93
93
|
if (this.pending + 1 > this.maxItems || this.bytes + size > this.maxBytes) {
|
|
94
94
|
if (this.stats.dropped++ === 0) {
|
|
95
|
-
console.warn(`tracereports:
|
|
95
|
+
console.warn(`tracereports: the send queue is full (${this.maxItems} events); new events are dropped`);
|
|
96
96
|
}
|
|
97
97
|
return false;
|
|
98
98
|
}
|
|
@@ -178,7 +178,7 @@ export class Sender {
|
|
|
178
178
|
}
|
|
179
179
|
this.notify();
|
|
180
180
|
if (items.length) {
|
|
181
|
-
console.warn(`tracereports: ${items.length}
|
|
181
|
+
console.warn(`tracereports: ${items.length} evidence events could not be sent and were lost`);
|
|
182
182
|
}
|
|
183
183
|
return items.length;
|
|
184
184
|
}
|