@arghajit/dummy 0.3.8 → 0.3.10

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.
@@ -0,0 +1,12 @@
1
+ export type PulseSeverityLevel = "Minor" | "Low" | "Medium" | "High" | "Critical";
2
+ export declare const pulse: {
3
+ /**
4
+ * Sets the severity level for the current test.
5
+ * * @param level - The severity level ('Minor' | 'Low' | 'Medium' | 'High' | 'Critical')
6
+ * @example
7
+ * test('Login', async () => {
8
+ * pulse.severity('Critical');
9
+ * });
10
+ */
11
+ severity: (level: PulseSeverityLevel) => void;
12
+ };
package/dist/pulse.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pulse = void 0;
4
+ const test_1 = require("@playwright/test");
5
+ exports.pulse = {
6
+ /**
7
+ * Sets the severity level for the current test.
8
+ * * @param level - The severity level ('Minor' | 'Low' | 'Medium' | 'High' | 'Critical')
9
+ * @example
10
+ * test('Login', async () => {
11
+ * pulse.severity('Critical');
12
+ * });
13
+ */
14
+ severity: (level) => {
15
+ const validLevels = ["Minor", "Low", "Medium", "High", "Critical"];
16
+ // Default to "Medium" if an invalid string is passed
17
+ const selectedLevel = validLevels.includes(level) ? level : "Medium";
18
+ // Add the annotation to Playwright's test info
19
+ test_1.test.info().annotations.push({
20
+ type: "pulse_severity",
21
+ description: selectedLevel,
22
+ });
23
+ },
24
+ };
@@ -3,3 +3,5 @@ export default PlaywrightPulseReporter;
3
3
  export { PlaywrightPulseReporter };
4
4
  export type { PlaywrightPulseReport } from "../lib/report-types";
5
5
  export type { TestResult, TestRun, TestStep, TestStatus } from "../types";
6
+ export { pulse } from "../pulse";
7
+ export type { PulseSeverityLevel } from "../pulse";
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PlaywrightPulseReporter = void 0;
3
+ exports.pulse = exports.PlaywrightPulseReporter = void 0;
4
4
  // src/reporter/index.ts
5
5
  const playwright_pulse_reporter_1 = require("./playwright-pulse-reporter");
6
6
  Object.defineProperty(exports, "PlaywrightPulseReporter", { enumerable: true, get: function () { return playwright_pulse_reporter_1.PlaywrightPulseReporter; } });
7
7
  // Export the reporter class as the default export for CommonJS compatibility
8
8
  // and also as a named export for potential ES module consumers.
9
9
  exports.default = playwright_pulse_reporter_1.PlaywrightPulseReporter;
10
+ // --- NEW: Export the pulse helper ---
11
+ // This allows: import { pulse } from '@arghajit/playwright-pulse-report';
12
+ var pulse_1 = require("../pulse"); // Adjust path based on where you placed pulse.ts
13
+ Object.defineProperty(exports, "pulse", { enumerable: true, get: function () { return pulse_1.pulse; } });
@@ -16,6 +16,7 @@ export declare class PlaywrightPulseReporter implements Reporter {
16
16
  printsToStdio(): boolean;
17
17
  onBegin(config: FullConfig, suite: Suite): void;
18
18
  onTestBegin(test: TestCase): void;
19
+ private _getSeverity;
19
20
  private getBrowserDetails;
20
21
  private processStep;
21
22
  onTestEnd(test: TestCase, result: PwTestResult): Promise<void>;
@@ -109,6 +109,10 @@ class PlaywrightPulseReporter {
109
109
  onTestBegin(test) {
110
110
  console.log(`Starting test: ${test.title}`);
111
111
  }
112
+ _getSeverity(annotations) {
113
+ const severityAnnotation = annotations.find((a) => a.type === "pulse_severity");
114
+ return (severityAnnotation === null || severityAnnotation === void 0 ? void 0 : severityAnnotation.description) || "Medium";
115
+ }
112
116
  getBrowserDetails(test) {
113
117
  var _a, _b, _c, _d;
114
118
  const project = (_a = test.parent) === null || _a === void 0 ? void 0 : _a.project();
@@ -267,6 +271,7 @@ class PlaywrightPulseReporter {
267
271
  snippet: (_l = result.error) === null || _l === void 0 ? void 0 : _l.snippet,
268
272
  codeSnippet: codeSnippet,
269
273
  tags: test.tags.map((tag) => tag.startsWith("@") ? tag.substring(1) : tag),
274
+ severity: this._getSeverity(test.annotations),
270
275
  screenshots: [],
271
276
  videoPath: [],
272
277
  tracePath: undefined,
@@ -31,6 +31,7 @@ export interface TestResult {
31
31
  snippet?: string;
32
32
  codeSnippet?: string;
33
33
  tags?: string[];
34
+ severity?: "Minor" | "Low" | "Medium" | "High" | "Critical";
34
35
  suiteName?: string;
35
36
  runId: string;
36
37
  browser: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arghajit/dummy",
3
3
  "author": "Arghajit Singha",
4
- "version": "0.3.8",
4
+ "version": "0.3.10",
5
5
  "description": "A Playwright reporter and dashboard for visualizing test results.",
6
6
  "homepage": "https://playwright-pulse-report.netlify.app/",
7
7
  "keywords": [
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  import * as fs from "fs";
3
2
  import * as path from "path";
4
3
  import { pathToFileURL } from "url";
@@ -24,15 +23,69 @@ async function findPlaywrightConfig() {
24
23
  }
25
24
 
26
25
  async function extractOutputDirFromConfig(configPath) {
26
+ let fileContent = "";
27
27
  try {
28
- let config;
28
+ fileContent = fs.readFileSync(configPath, "utf-8");
29
+ } catch (e) {
30
+ // If we can't read the file, we can't parse or import it.
31
+ return null;
32
+ }
33
+
34
+ // 1. Strategy: Text Parsing (Safe & Fast)
35
+ // We try to read the file as text first. This finds the outputDir without
36
+ // triggering any Node.js warnings or errors.
37
+ try {
38
+ // Regex matches: outputDir: "value" or outputDir: 'value'
39
+ const match = fileContent.match(/outputDir:\s*["']([^"']+)["']/);
40
+
41
+ if (match && match[1]) {
42
+ return path.resolve(process.cwd(), match[1]);
43
+ }
44
+ } catch (e) {
45
+ // Ignore text reading errors
46
+ }
47
+
48
+ // 2. Safety Check: Detect ESM in CJS to Prevent Node Warnings
49
+ // The warning "To load an ES module..." happens when we try to import()
50
+ // a .js file containing ESM syntax (import/export) in a CJS package.
51
+ // We explicitly check for this and ABORT the import if found.
52
+ if (configPath.endsWith(".js")) {
53
+ let isModulePackage = false;
54
+ try {
55
+ const pkgPath = path.resolve(process.cwd(), "package.json");
56
+ if (fs.existsSync(pkgPath)) {
57
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
58
+ isModulePackage = pkg.type === "module";
59
+ }
60
+ } catch (e) {}
61
+
62
+ if (!isModulePackage) {
63
+ // Heuristic: Check for ESM syntax (import/export at start of lines)
64
+ const hasEsmSyntax =
65
+ /^\s*import\s+/m.test(fileContent) ||
66
+ /^\s*export\s+/m.test(fileContent);
67
+
68
+ if (hasEsmSyntax) {
69
+ // We found ESM syntax in a .js file within a CJS project.
70
+ // Attempting to import this WILL trigger the Node.js warning.
71
+ // Since regex failed to find outputDir, and we can't import safely, we abort now.
72
+ return null;
73
+ }
74
+ }
75
+ }
29
76
 
77
+ // 3. Strategy: Dynamic Import
78
+ // If we passed the safety check, we try to import the config.
79
+ try {
80
+ let config;
30
81
  const configDir = dirname(configPath);
31
- // const originalDirname = global.__dirname; // Not strictly needed in ESM context usually, but keeping if you rely on it elsewhere
32
- // const originalFilename = global.__filename;
82
+ const originalDirname = global.__dirname;
83
+ const originalFilename = global.__filename;
33
84
 
34
- // 1. Try Loading via Import (Existing Logic)
35
85
  try {
86
+ global.__dirname = configDir;
87
+ global.__filename = configPath;
88
+
36
89
  if (configPath.endsWith(".ts")) {
37
90
  try {
38
91
  const { register } = await import("node:module");
@@ -52,20 +105,19 @@ async function extractOutputDirFromConfig(configPath) {
52
105
  config = await import(pathToFileURL(configPath).href);
53
106
  }
54
107
 
55
- // Extract from default export or direct export
108
+ // Handle Default Export
56
109
  if (config && config.default) {
57
110
  config = config.default;
58
111
  }
59
112
 
60
113
  if (config) {
61
- // Check specific reporter config
114
+ // Check for Reporter Config
62
115
  if (config.reporter) {
63
116
  const reporters = Array.isArray(config.reporter)
64
117
  ? config.reporter
65
118
  : [config.reporter];
66
119
 
67
120
  for (const reporter of reporters) {
68
- // reporter can be ["list"] or ["html", { outputFolder: '...' }]
69
121
  const reporterName = Array.isArray(reporter)
70
122
  ? reporter[0]
71
123
  : reporter;
@@ -80,45 +132,28 @@ async function extractOutputDirFromConfig(configPath) {
80
132
  reporterName.includes("@arghajit/dummy"))
81
133
  ) {
82
134
  if (reporterOptions && reporterOptions.outputDir) {
83
- // Found it via Import!
84
135
  return path.resolve(process.cwd(), reporterOptions.outputDir);
85
136
  }
86
137
  }
87
138
  }
88
139
  }
89
140
 
90
- // Check global outputDir
141
+ // Check for Global outputDir
91
142
  if (config.outputDir) {
92
143
  return path.resolve(process.cwd(), config.outputDir);
93
144
  }
94
145
  }
95
- } catch (importError) {
96
- // Import failed (likely the SyntaxError you saw).
97
- // We suppress this error and fall through to the text-parsing fallback below.
146
+ } finally {
147
+ // Clean up globals
148
+ global.__dirname = originalDirname;
149
+ global.__filename = originalFilename;
98
150
  }
99
-
100
- // 2. Fallback: Parse file as text (New Logic)
101
- // This runs if import failed or if import worked but didn't have the specific config
102
- try {
103
- const fileContent = fs.readFileSync(configPath, "utf-8");
104
-
105
- // Regex to find: outputDir: "some/path" or 'some/path' inside the reporter config or global
106
- // This is a simple heuristic to avoid the "Cannot use import statement" error
107
- const match = fileContent.match(/outputDir:\s*["']([^"']+)["']/);
108
-
109
- if (match && match[1]) {
110
- console.log(`Found outputDir via text parsing: ${match[1]}`);
111
- return path.resolve(process.cwd(), match[1]);
112
- }
113
- } catch (readError) {
114
- // If reading fails, just return null silently
115
- }
116
-
117
- return null;
118
151
  } catch (error) {
119
- // Final safety net: Do not log the stack trace to avoid cluttering the console
152
+ // SILENT CATCH: Do NOT log anything here.
120
153
  return null;
121
154
  }
155
+
156
+ return null;
122
157
  }
123
158
 
124
159
  export async function getOutputDir(customOutputDirFromArgs = null) {
@@ -217,6 +217,40 @@ function generateMinifiedHTML(reportData) {
217
217
  const testFileParts = test.name.split(" > ");
218
218
  const testTitle =
219
219
  testFileParts[testFileParts.length - 1] || "Unnamed Test";
220
+
221
+ // --- NEW: Severity Logic ---
222
+ const severity = test.severity || "Medium";
223
+ const getSeverityColor = (level) => {
224
+ switch (level) {
225
+ case "Minor":
226
+ return "#006064";
227
+ case "Low":
228
+ return "#E65100";
229
+ case "Medium":
230
+ return "#FFA07A";
231
+ case "High":
232
+ return "#B71C1C";
233
+ case "Critical":
234
+ return "#3E0000";
235
+ default:
236
+ return "#FFA07A";
237
+ }
238
+ };
239
+ // We use inline styles here to ensure they render correctly in emails
240
+ const severityBadge = `<span style="background-color: ${getSeverityColor(
241
+ severity
242
+ )}; font-size: 0.8em; font-weight: 600; padding: 3px 8px; border-radius: 4px; color: #fff; margin-left: 10px; white-space: nowrap;">${severity}</span>`;
243
+
244
+ // --- NEW: Tags Logic ---
245
+ const tagsBadges = (test.tags || [])
246
+ .map(
247
+ (tag) =>
248
+ `<span style="background-color: #7f8c8d; font-size: 0.8em; font-weight: 600; padding: 3px 8px; border-radius: 4px; color: #fff; margin-left: 5px; white-space: nowrap;">${sanitizeHTML(
249
+ tag
250
+ )}</span>`
251
+ )
252
+ .join("");
253
+
220
254
  html += `
221
255
  <li class="test-item ${getStatusClass(test.status)}"
222
256
  data-test-name-min="${sanitizeHTML(testTitle.toLowerCase())}"
@@ -230,9 +264,9 @@ function generateMinifiedHTML(reportData) {
230
264
  <span class="test-title-text" title="${sanitizeHTML(
231
265
  test.name
232
266
  )}">${sanitizeHTML(testTitle)}</span>
233
- <span class="test-status-label">${String(
234
- test.status
235
- ).toUpperCase()}</span>
267
+
268
+ ${severityBadge}
269
+ ${tagsBadges}
236
270
  </li>
237
271
  `;
238
272
  });
@@ -250,8 +284,8 @@ function generateMinifiedHTML(reportData) {
250
284
  <head>
251
285
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
252
286
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
253
- <link rel="icon" type="image/png" href="https://i.postimg.cc/v817w4sg/logo.png">
254
- <link rel="apple-touch-icon" href="https://i.postimg.cc/v817w4sg/logo.png">
287
+ <link rel="icon" type="image/png" href="https://ocpaxmghzmfbuhxzxzae.supabase.co/storage/v1/object/public/images/pulse-report/playwright_pulse_icon.png">
288
+ <link rel="apple-touch-icon" href="https://ocpaxmghzmfbuhxzxzae.supabase.co/storage/v1/object/public/images/pulse-report/playwright_pulse_icon.png">
255
289
  <title>Playwright Pulse Summary Report</title>
256
290
  <style>
257
291
  :root {
@@ -492,7 +526,7 @@ function generateMinifiedHTML(reportData) {
492
526
  <div class="container">
493
527
  <header class="report-header">
494
528
  <div class="report-header-title">
495
- <img id="report-logo" src="https://i.postimg.cc/v817w4sg/logo.png" alt="Report Logo">
529
+ <img id="report-logo" src="https://ocpaxmghzmfbuhxzxzae.supabase.co/storage/v1/object/public/images/pulse-report/playwright_pulse_icon.png" alt="Report Logo">
496
530
  <h1>Playwright Pulse Summary</h1>
497
531
  </div>
498
532
  <div class="run-info">
@@ -527,8 +561,24 @@ function generateMinifiedHTML(reportData) {
527
561
  </section>
528
562
 
529
563
  <section class="test-results-section">
530
- <h1 class="section-title">Test Case Summary</h1>
531
-
564
+ <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; margin-top: 30px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid var(--secondary-color);">
565
+ <h1 style="margin: 0; font-size: 1.5em; color: var(--primary-color);">Test Case Summary</h1>
566
+ <div style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 0.75em;">
567
+ <span style="font-weight: 600; color: var(--dark-gray-color);">Legend:</span>
568
+
569
+ <span style="margin-left: 4px; font-weight: 600; color: var(--text-color);">Severity:</span>
570
+
571
+ <span style="background-color: #006064; color: #fff; padding: 2px 6px; border-radius: 3px;">Minor</span>
572
+ <span style="background-color: #E65100; color: #fff; padding: 2px 6px; border-radius: 3px;">Low</span>
573
+ <span style="background-color: #FFA07A; color: #fff; padding: 2px 6px; border-radius: 3px;">Medium</span>
574
+ <span style="background-color: #B71C1C; color: #fff; padding: 2px 6px; border-radius: 3px;">High</span>
575
+ <span style="background-color: #3E0000; color: #fff; padding: 2px 6px; border-radius: 3px;">Critical</span>
576
+
577
+ <span style="border-left: 1px solid #ccc; height: 14px; margin: 0 4px;"></span>
578
+
579
+ <span style="background-color: #7f8c8d; color: #fff; padding: 2px 6px; border-radius: 3px;">Tags</span>
580
+ </div>
581
+ </div>
532
582
  <div class="filters-section">
533
583
  <input type="text" id="filter-min-name" placeholder="Search by test name...">
534
584
  <select id="filter-min-status">
@@ -1748,7 +1748,7 @@ function generateSpecDurationChart(results) {
1748
1748
  */
1749
1749
  function generateDescribeDurationChart(results) {
1750
1750
  if (!results || results.length === 0)
1751
- return '<div class="no-data">No results available.</div>';
1751
+ return '<div class="no-data">Seems like there is test describe block available in the executed test suite.</div>';
1752
1752
 
1753
1753
  const describeMap = new Map();
1754
1754
  let foundAnyDescribe = false;
@@ -1918,6 +1918,26 @@ function generateHTML(reportData, trendData = null) {
1918
1918
  const testFileParts = test.name.split(" > ");
1919
1919
  const testTitle =
1920
1920
  testFileParts[testFileParts.length - 1] || "Unnamed Test";
1921
+ // --- NEW: Severity Logic ---
1922
+ const severity = test.severity || "Medium";
1923
+ const getSeverityColor = (level) => {
1924
+ switch (level) {
1925
+ case "Minor":
1926
+ return "#006064"; // contrast ~7.35:1 vs white
1927
+ case "Low":
1928
+ return "#E65100"; // contrast ~4.9:1 vs white
1929
+ case "High":
1930
+ return "#B71C1C"; // contrast ~6.57:1 vs white
1931
+ case "Critical":
1932
+ return "#3E0000"; // contrast ~17.4:1 vs white
1933
+ default:
1934
+ return "#BF360C"; // Medium, contrast ~5.6:1 vs white
1935
+ }
1936
+ };
1937
+ const severityColor = getSeverityColor(severity);
1938
+ // We reuse 'status-badge' class for size/font consistency, but override background color
1939
+ const severityBadge = `<span class="status-badge" style="background-color: ${severityColor}; margin-right: 8px;">${severity}</span>`;
1940
+ // ---------------------------
1921
1941
  const generateStepsHTML = (steps, depth = 0) => {
1922
1942
  if (!steps || steps.length === 0)
1923
1943
  return "<div class='no-steps'>No steps recorded for this test.</div>";
@@ -2011,6 +2031,7 @@ function generateHTML(reportData, trendData = null) {
2011
2031
  <span class="test-case-browser">(${sanitizeHTML(browser)})</span>
2012
2032
  </div>
2013
2033
  <div class="test-case-meta">
2034
+ ${severityBadge}
2014
2035
  ${
2015
2036
  test.tags && test.tags.length > 0
2016
2037
  ? test.tags
@@ -2433,6 +2454,7 @@ function generateHTML(reportData, trendData = null) {
2433
2454
  .status-badge-small.status-failed { background-color: var(--danger-color); }
2434
2455
  .status-badge-small.status-skipped { background-color: var(--warning-color); }
2435
2456
  .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
2457
+ .badge-severity { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; color: white; text-transform: uppercase; margin-right: 8px; vertical-align: middle; }
2436
2458
  .no-data, .no-tests, .no-steps, .no-data-chart { padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em; background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0; border: 1px dashed var(--medium-gray-color); }
2437
2459
  .no-data-chart {font-size: 0.95em; padding: 18px;}
2438
2460
  .ai-failure-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 22px; }
@@ -1885,7 +1885,7 @@ function generateSpecDurationChart(results) {
1885
1885
  */
1886
1886
  function generateDescribeDurationChart(results) {
1887
1887
  if (!results || results.length === 0)
1888
- return '<div class="no-data">No results available.</div>';
1888
+ return '<div class="no-data">Seems like there is test describe block available in the executed test suite.</div>';
1889
1889
 
1890
1890
  const describeMap = new Map();
1891
1891
  let foundAnyDescribe = false;
@@ -2054,6 +2054,26 @@ function generateHTML(reportData, trendData = null) {
2054
2054
  const testFileParts = test.name.split(" > ");
2055
2055
  const testTitle =
2056
2056
  testFileParts[testFileParts.length - 1] || "Unnamed Test";
2057
+ // --- NEW: Severity Logic ---
2058
+ const severity = test.severity || "Medium";
2059
+ const getSeverityColor = (level) => {
2060
+ switch (level) {
2061
+ case "Minor":
2062
+ return "#006064"; // contrast ~7.35:1 vs white
2063
+ case "Low":
2064
+ return "#E65100"; // contrast ~4.9:1 vs white
2065
+ case "High":
2066
+ return "#B71C1C"; // contrast ~6.57:1 vs white
2067
+ case "Critical":
2068
+ return "#3E0000"; // contrast ~17.4:1 vs white
2069
+ default:
2070
+ return "#BF360C"; // Medium, contrast ~5.6:1 vs white
2071
+ }
2072
+ };
2073
+ const severityColor = getSeverityColor(severity);
2074
+ // We reuse 'status-badge' class for size/font consistency, but override background color
2075
+ const severityBadge = `<span class="status-badge" style="background-color: ${severityColor}; margin-right: 8px;">${severity}</span>`;
2076
+ // ---------------------------
2057
2077
  const generateStepsHTML = (steps, depth = 0) => {
2058
2078
  if (!steps || steps.length === 0)
2059
2079
  return "<div class='no-steps'>No steps recorded for this test.</div>";
@@ -2163,13 +2183,16 @@ function generateHTML(reportData, trendData = null) {
2163
2183
  testTitle
2164
2184
  )}</span><span class="test-case-browser">(${sanitizeHTML(
2165
2185
  browser
2166
- )})</span></div><div class="test-case-meta">${
2186
+ )})</span></div><div class="test-case-meta">
2187
+ ${severityBadge}
2188
+ ${
2167
2189
  test.tags && test.tags.length > 0
2168
2190
  ? test.tags
2169
2191
  .map((t) => `<span class="tag">${sanitizeHTML(t)}</span>`)
2170
2192
  .join(" ")
2171
2193
  : ""
2172
- }<span class="test-duration">${formatDuration(
2194
+ }
2195
+ <span class="test-duration">${formatDuration(
2173
2196
  test.duration
2174
2197
  )}</span></div></div>
2175
2198
  <div class="test-case-content" style="display: none;">
@@ -2620,6 +2643,7 @@ aspect-ratio: 16 / 9;
2620
2643
  .status-badge-small.status-failed { background-color: var(--danger-color); }
2621
2644
  .status-badge-small.status-skipped { background-color: var(--warning-color); }
2622
2645
  .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
2646
+ .badge-severity { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; color: white; text-transform: uppercase; margin-right: 8px; vertical-align: middle; }
2623
2647
  .no-data, .no-tests, .no-steps, .no-data-chart { padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em; background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0; border: 1px dashed var(--medium-gray-color); }
2624
2648
  .no-data-chart {font-size: 0.95em; padding: 18px;}
2625
2649
  .ai-failure-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 22px; }