tracereports 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracereports",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "TraceReports client for JavaScript/TypeScript: Playwright Test reporter and fixtures, Selenium WebDriver helpers and a client for any runner.",
5
5
  "keywords": [
6
6
  "testing",
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.3.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
 
@@ -369,7 +370,8 @@ export class TraceTest {
369
370
  * base64 (Selenium `driver.takeScreenshot()`).
370
371
  */
371
372
  screenshot(image, message = "", status = "INFO") {
372
- if (!this.active || !image) return;
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`;
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
- fs.mkdirSync(path.join(directory, "bodies"), { recursive: true });
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 si el proyecto
18
- // no tiene `screenshot: "only-on-failure"`.
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
- const mode = testInfo.project.use?.screenshot;
173
- const auto = mode === "on" || mode === "only-on-failure" || mode?.mode === "on" || mode?.mode === "only-on-failure";
174
- if (!auto) {
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("Captura al fallar", { body: await page.screenshot({ timeout: 5000 }), contentType: "image/png" });
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 (att.contentType?.startsWith("image/")) ct.screenshot(body, att.name === "screenshot" ? "Captura al fallar" : att.name,
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
@@ -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
- /** Captura de pantalla como base64 (lo que acepta `t.screenshot`), o null. */
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
- try {
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 {