ortoni-report 4.0.2 → 4.0.3

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,13 @@
1
+ /**
2
+ * Merge shard JSON files into a single report while keeping ALL test results.
3
+ *
4
+ * options:
5
+ * - dir?: folder where shard files exist (default: "ortoni-report")
6
+ * - file?: output file name for final HTML (default: "ortoni-report.html")
7
+ * - saveHistory?: boolean | undefined -> if provided, overrides shard/userConfig
8
+ */
9
+ export declare function mergeAllData(options?: {
10
+ dir?: string;
11
+ file?: string;
12
+ saveHistory?: boolean;
13
+ }): Promise<void>;
@@ -0,0 +1,182 @@
1
+ // src/mergeAllData.ts
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { DatabaseManager } from "./helpers/databaseManager";
5
+ import { HTMLGenerator } from "./helpers/HTMLGenerator";
6
+ import { FileManager } from "./helpers/fileManager";
7
+ /**
8
+ * Merge shard JSON files into a single report while keeping ALL test results.
9
+ *
10
+ * options:
11
+ * - dir?: folder where shard files exist (default: "ortoni-report")
12
+ * - file?: output file name for final HTML (default: "ortoni-report.html")
13
+ * - saveHistory?: boolean | undefined -> if provided, overrides shard/userConfig
14
+ */
15
+ export async function mergeAllData(options = {}) {
16
+ const folderPath = options.dir || "ortoni-report";
17
+ console.info(`Ortoni Report: Merging shard files in folder: ${folderPath}`);
18
+ if (!fs.existsSync(folderPath)) {
19
+ console.error(`Ortoni Report: folder "${folderPath}" does not exist.`);
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+ const filenames = fs
24
+ .readdirSync(folderPath)
25
+ .filter((f) => f.startsWith("ortoni-shard-") && f.endsWith(".json"));
26
+ if (filenames.length === 0) {
27
+ console.error("Ortoni Report: ❌ No shard files found to merge.");
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+ // deterministic sort by numeric shard index if available (ortoni-shard-<current>-of-<total>.json)
32
+ const shardFileIndex = (name) => {
33
+ const m = name.match(/ortoni-shard-(\d+)-of-(\d+)\.json$/);
34
+ return m ? parseInt(m[1], 10) : null;
35
+ };
36
+ const sortedFiles = filenames
37
+ .map((f) => ({ f, idx: shardFileIndex(f) }))
38
+ .sort((a, b) => {
39
+ if (a.idx === null && b.idx === null)
40
+ return a.f.localeCompare(b.f);
41
+ if (a.idx === null)
42
+ return 1;
43
+ if (b.idx === null)
44
+ return -1;
45
+ return a.idx - b.idx;
46
+ })
47
+ .map((x) => x.f);
48
+ // Merge state
49
+ const allResults = []; // keep every test entry from every shard
50
+ const projectSet = new Set();
51
+ let totalDurationSum = 0; // sum of shard durations (ms)
52
+ let mergedUserConfig = null;
53
+ let mergedUserMeta = null;
54
+ const badShards = [];
55
+ const shardCounts = {};
56
+ const shardDurMap = {}; // used duration per shard (ms)
57
+ for (const file of sortedFiles) {
58
+ const fullPath = path.join(folderPath, file);
59
+ try {
60
+ const shardRaw = fs.readFileSync(fullPath, "utf-8");
61
+ const shardData = JSON.parse(shardRaw);
62
+ // Validate results array
63
+ if (!Array.isArray(shardData.results)) {
64
+ console.warn(`Ortoni Report: Shard ${file} missing results array — skipping.`);
65
+ badShards.push(file);
66
+ continue;
67
+ }
68
+ // Append all results (keep duplicates)
69
+ shardData.results.forEach((r) => allResults.push(r));
70
+ shardCounts[file] = shardData.results.length;
71
+ // Merge project sets
72
+ if (Array.isArray(shardData.projectSet)) {
73
+ shardData.projectSet.forEach((p) => projectSet.add(p));
74
+ }
75
+ // Duration handling:
76
+ // Accept shardData.totalDuration when it is a number (including 0).
77
+ // Fallback: if shardData.totalDuration is missing or not a number, sum per-test durations inside the shard.
78
+ const rawShardDur = shardData.totalDuration;
79
+ let durToAdd = 0;
80
+ let perTestSum = 0;
81
+ if (typeof rawShardDur === "number") {
82
+ // Accept numeric durations (including 0)
83
+ durToAdd = rawShardDur;
84
+ }
85
+ else {
86
+ // fallback: sum per-test durations (coerce to Number)
87
+ perTestSum = Array.isArray(shardData.results)
88
+ ? shardData.results.reduce((acc, t) => acc + (Number(t?.duration) || 0), 0)
89
+ : 0;
90
+ durToAdd = perTestSum;
91
+ }
92
+ // accumulate
93
+ totalDurationSum += durToAdd;
94
+ shardDurMap[file] = durToAdd;
95
+ // Merge userConfig/userMeta conservatively (prefer first non-empty value)
96
+ if (shardData.userConfig) {
97
+ if (!mergedUserConfig)
98
+ mergedUserConfig = { ...shardData.userConfig };
99
+ else {
100
+ Object.keys(shardData.userConfig).forEach((k) => {
101
+ if (mergedUserConfig[k] === undefined ||
102
+ mergedUserConfig[k] === null ||
103
+ mergedUserConfig[k] === "") {
104
+ mergedUserConfig[k] = shardData.userConfig[k];
105
+ }
106
+ else if (shardData.userConfig[k] !== mergedUserConfig[k]) {
107
+ console.warn(`Ortoni Report: userConfig mismatch for key "${k}" between shards. Using first value "${mergedUserConfig[k]}".`);
108
+ }
109
+ });
110
+ }
111
+ }
112
+ if (shardData.userMeta) {
113
+ if (!mergedUserMeta)
114
+ mergedUserMeta = { ...shardData.userMeta };
115
+ else {
116
+ mergedUserMeta.meta = {
117
+ ...(mergedUserMeta.meta || {}),
118
+ ...(shardData.userMeta.meta || {}),
119
+ };
120
+ }
121
+ }
122
+ }
123
+ catch (err) {
124
+ console.error(`Ortoni Report: Failed to parse shard ${file}:`, err);
125
+ badShards.push(file);
126
+ continue;
127
+ }
128
+ } // end for each shard
129
+ if (badShards.length > 0) {
130
+ console.warn(`Ortoni Report: Completed merge with ${badShards.length} bad shard(s) skipped:`, badShards);
131
+ }
132
+ // final results preserved with duplicates
133
+ const totalDuration = totalDurationSum; // in ms
134
+ // Determine whether to persist history:
135
+ // Priority: explicit options.saveHistory -> mergedUserConfig.saveHistory -> default true
136
+ const saveHistoryFromOptions = typeof options.saveHistory === "boolean" ? options.saveHistory : undefined;
137
+ const saveHistoryFromShard = mergedUserConfig && typeof mergedUserConfig.saveHistory === "boolean"
138
+ ? mergedUserConfig.saveHistory
139
+ : undefined;
140
+ const saveHistory = saveHistoryFromOptions ?? saveHistoryFromShard ?? true;
141
+ let dbManager;
142
+ let runId;
143
+ if (saveHistory) {
144
+ try {
145
+ dbManager = new DatabaseManager();
146
+ const dbPath = path.join(folderPath, "ortoni-data-history.sqlite");
147
+ await dbManager.initialize(dbPath);
148
+ runId = await dbManager.saveTestRun();
149
+ if (typeof runId === "number") {
150
+ // Save all results (your saveTestResults may batch internally)
151
+ await dbManager.saveTestResults(runId, allResults);
152
+ console.info(`Ortoni Report: Saved ${allResults.length} results to DB (runId=${runId}).`);
153
+ }
154
+ else {
155
+ console.warn("Ortoni Report: Failed to create test run in DB; proceeding without saving results.");
156
+ }
157
+ }
158
+ catch (err) {
159
+ console.error("Ortoni Report: Error while saving history to DB. Proceeding without DB:", err);
160
+ dbManager = undefined;
161
+ runId = undefined;
162
+ }
163
+ }
164
+ else {
165
+ console.info("Ortoni Report: Skipping history save (saveHistory=false). (Typical for CI runs)");
166
+ }
167
+ // Generate final report
168
+ const htmlGenerator = new HTMLGenerator({ ...(mergedUserConfig || {}), meta: mergedUserMeta?.meta }, dbManager);
169
+ const finalReportData = await htmlGenerator.generateFinalReport(
170
+ // filteredResults: typically filter out skipped for display (keeps existing behavior)
171
+ allResults.filter((r) => r.status !== "skipped"), totalDuration, allResults, projectSet // pass Set<string> as original generateFinalReport expects
172
+ );
173
+ // Write final HTML file
174
+ const fileManager = new FileManager(folderPath);
175
+ const outputFileName = options.file || "ortoni-report.html";
176
+ const outputPath = fileManager.writeReportFile(outputFileName, finalReportData);
177
+ // Logs & debugging summary
178
+ console.log(`✅ Final merged report generated at ${await outputPath}`);
179
+ console.log(`✅ Shards merged: ${sortedFiles.length}`);
180
+ console.log(`✅ Tests per shard:`, shardCounts);
181
+ console.log(`✅ Total tests merged ${allResults.length}`);
182
+ }
@@ -1732,7 +1732,7 @@ var TestResultProcessor = class {
1732
1732
  testId: `${filePath}:${projectName}:${title}`
1733
1733
  };
1734
1734
  attachFiles(
1735
- test.id,
1735
+ import_path5.default.join(test.id, `retry-${result.retry}`),
1736
1736
  result,
1737
1737
  testResult,
1738
1738
  ortoniConfig,
@@ -1317,7 +1317,7 @@ var TestResultProcessor = class {
1317
1317
  testId: `${filePath}:${projectName}:${title}`
1318
1318
  };
1319
1319
  attachFiles(
1320
- test.id,
1320
+ path2.join(test.id, `retry-${result.retry}`),
1321
1321
  result,
1322
1322
  testResult,
1323
1323
  ortoniConfig,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Configuration options for OrtoniReport.
3
+ */
4
+ export interface OrtoniReportConfig {
5
+ /**
6
+ * Open the report in local host (Trace viewer is accessible only in localhost)
7
+ * @example "always"| "never"| "on-failure";
8
+ * @default "never"
9
+ */
10
+ open?: "always" | "never" | "on-failure";
11
+ /**
12
+ * The title of the HTML report.
13
+ * @example "Ortoni Playwright Test Report"
14
+ */
15
+ title?: string;
16
+ /**
17
+ * Add project on the list of the tests? (Filtering projects still works if hidden)
18
+ * @example true to display, false to hide.
19
+ * @default false
20
+ */
21
+ showProject?: boolean;
22
+ /**
23
+ * The name of the project.
24
+ * @example "Ortoni Project"
25
+ */
26
+ projectName?: string;
27
+ /**
28
+ * The name of the author.
29
+ * @example "Koushik Chatterjee"
30
+ */
31
+ authorName?: string;
32
+ /**
33
+ * The type of tests being run.
34
+ * @example "Regression"
35
+ */
36
+ testType?: string;
37
+ /**
38
+ * If true, images will be encoded in base64.
39
+ * Not recommended to use if many screenshots are present.
40
+ * @default false
41
+ * @example true
42
+ */
43
+ base64Image?: boolean;
44
+ /**
45
+ * The local relative of the logo image.
46
+ * Recommended to keep within the report genrated folder.
47
+ * @default "ortoni-report/logo.png"
48
+ * @example "logo.png"
49
+ */
50
+ logo?: string;
51
+ /**
52
+ * The filename to the html report.
53
+ * @example "index.html"
54
+ * @default "ortoni-report.html"
55
+ */
56
+ filename?: string;
57
+ /**
58
+ * The folder name.
59
+ * @example "report"
60
+ * @default "ortoni-report"
61
+ */
62
+ folderPath?: string;
63
+ /**
64
+ * Port to connect
65
+ * @example 3600
66
+ */
67
+ port?: number;
68
+ /**
69
+ * Display console logs?
70
+ * @example boolean
71
+ * @default true
72
+ */
73
+ stdIO?: boolean;
74
+ /**
75
+ * Metadata for the report. ['Test Cycle': 'Cycle 1', 'Test Environment':'QA', etc]
76
+ * @example { "key": "value" } as string
77
+ */
78
+ meta?: Record<string, string>;
79
+ /**
80
+ * Save the history of the reports in a SQL file to be used in future reports.
81
+ * The history file will be saved in the report folder.
82
+ * @default true
83
+ * @example false (to disable)
84
+ */
85
+ saveHistory?: boolean;
86
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ export interface Steps {
2
+ snippet: string | undefined;
3
+ title: string;
4
+ location: string;
5
+ }
6
+ export interface TestResultData {
7
+ suiteHierarchy: string;
8
+ key: string;
9
+ annotations: any[];
10
+ testTags: string[];
11
+ location: string;
12
+ retryAttemptCount: number;
13
+ projectName: any;
14
+ suite: any;
15
+ title: string;
16
+ status: "passed" | "failed" | "timedOut" | "skipped" | "interrupted" | "expected" | "unexpected" | "flaky";
17
+ flaky: string;
18
+ duration: number;
19
+ errors: any[];
20
+ steps: Steps[];
21
+ logs: string;
22
+ screenshotPath?: string | null | undefined;
23
+ screenshots?: string[];
24
+ filePath: string;
25
+ filters: Set<string>;
26
+ tracePath?: string;
27
+ videoPath?: string[];
28
+ markdownPath?: string;
29
+ base64Image: boolean | undefined;
30
+ testId: string;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import { TestResult } from "@playwright/test/reporter";
2
+ import { Steps, TestResultData } from "../types/testResults";
3
+ import { OrtoniReportConfig } from "../types/reporterConfig";
4
+ export declare function attachFiles(subFolder: string, result: TestResult, testResult: TestResultData, config: OrtoniReportConfig, steps?: Steps[], errors?: string[]): void;
@@ -0,0 +1,87 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { convertMarkdownToHtml } from "../helpers/markdownConverter";
4
+ export function attachFiles(subFolder, result, testResult, config, steps, errors) {
5
+ const folderPath = config.folderPath || "ortoni-report";
6
+ const attachmentsFolder = path.join(folderPath, "ortoni-data", "attachments", subFolder);
7
+ if (!fs.existsSync(attachmentsFolder)) {
8
+ fs.mkdirSync(attachmentsFolder, { recursive: true });
9
+ }
10
+ if (!result.attachments)
11
+ return;
12
+ const { base64Image } = config;
13
+ testResult.screenshots = [];
14
+ testResult.videoPath = [];
15
+ result.attachments.forEach((attachment) => {
16
+ const { contentType, name, path: attachmentPath, body } = attachment;
17
+ if (!attachmentPath && !body)
18
+ return;
19
+ const fileName = attachmentPath
20
+ ? path.basename(attachmentPath)
21
+ : `${name}.${getFileExtension(contentType)}`;
22
+ const relativePath = path.join("ortoni-data", "attachments", subFolder, fileName);
23
+ const fullPath = path.join(attachmentsFolder, fileName);
24
+ if (contentType === "image/png") {
25
+ handleImage(attachmentPath, body, base64Image, fullPath, relativePath, testResult);
26
+ }
27
+ else if (name === "video") {
28
+ handleAttachment(attachmentPath, fullPath, relativePath, "videoPath", testResult);
29
+ }
30
+ else if (name === "trace") {
31
+ handleAttachment(attachmentPath, fullPath, relativePath, "tracePath", testResult);
32
+ }
33
+ else if (name === "error-context") {
34
+ handleAttachment(attachmentPath, fullPath, relativePath, "markdownPath", testResult, steps, errors);
35
+ }
36
+ });
37
+ }
38
+ function handleImage(attachmentPath, body, base64Image, fullPath, relativePath, testResult) {
39
+ let screenshotPath = "";
40
+ if (attachmentPath) {
41
+ try {
42
+ const screenshotContent = fs.readFileSync(attachmentPath, base64Image ? "base64" : undefined);
43
+ screenshotPath = base64Image
44
+ ? `data:image/png;base64,${screenshotContent}`
45
+ : relativePath;
46
+ if (!base64Image) {
47
+ fs.copyFileSync(attachmentPath, fullPath);
48
+ }
49
+ }
50
+ catch (error) {
51
+ console.error(`OrtoniReport: Failed to read screenshot file: ${attachmentPath}`, error);
52
+ }
53
+ }
54
+ else if (body) {
55
+ screenshotPath = `data:image/png;base64,${body.toString("base64")}`;
56
+ }
57
+ if (screenshotPath) {
58
+ testResult.screenshots?.push(screenshotPath);
59
+ }
60
+ }
61
+ function handleAttachment(attachmentPath, fullPath, relativePath, resultKey, testResult, steps, errors) {
62
+ if (attachmentPath) {
63
+ fs.copyFileSync(attachmentPath, fullPath);
64
+ if (resultKey === "videoPath") {
65
+ testResult[resultKey]?.push(relativePath);
66
+ }
67
+ else if (resultKey === "tracePath") {
68
+ testResult[resultKey] = relativePath;
69
+ }
70
+ }
71
+ if (resultKey === "markdownPath" && errors) {
72
+ const htmlPath = fullPath.replace(/\.md$/, ".html");
73
+ const htmlRelativePath = relativePath.replace(/\.md$/, ".html");
74
+ convertMarkdownToHtml(fullPath, htmlPath);
75
+ testResult[resultKey] = htmlRelativePath;
76
+ return;
77
+ }
78
+ }
79
+ function getFileExtension(contentType) {
80
+ const extensions = {
81
+ "image/png": "png",
82
+ "video/webm": "webm",
83
+ "application/zip": "zip",
84
+ "text/markdown": "md",
85
+ };
86
+ return extensions[contentType] || "unknown";
87
+ }
@@ -0,0 +1 @@
1
+ export declare function startReportServer(reportFolder: string, reportFilename: string, port: number, open: string | undefined): void;
@@ -0,0 +1,61 @@
1
+ import express from "express";
2
+ import path from "path";
3
+ import { spawn } from "child_process";
4
+ export function startReportServer(reportFolder, reportFilename, port = 2004, open) {
5
+ const app = express();
6
+ app.use(express.static(reportFolder, { index: false }));
7
+ app.get("/", (_req, res) => {
8
+ try {
9
+ res.sendFile(path.resolve(reportFolder, reportFilename));
10
+ }
11
+ catch (error) {
12
+ console.error("Ortoni Report: Error sending report file:", error);
13
+ res.status(500).send("Error loading report");
14
+ }
15
+ });
16
+ try {
17
+ const server = app.listen(port, () => {
18
+ console.log(`Server is running at http://localhost:${port} \nPress Ctrl+C to stop.`);
19
+ if (open === "always" || open === "on-failure") {
20
+ try {
21
+ openBrowser(`http://localhost:${port}`);
22
+ }
23
+ catch (error) {
24
+ console.error("Ortoni Report: Error opening browser:", error);
25
+ }
26
+ }
27
+ });
28
+ server.on("error", (error) => {
29
+ if (error.code === "EADDRINUSE") {
30
+ console.error(`Ortoni Report: Port ${port} is already in use. Trying a different port...`);
31
+ }
32
+ else {
33
+ console.error("Ortoni Report: Server error:", error);
34
+ }
35
+ });
36
+ }
37
+ catch (error) {
38
+ console.error("Ortoni Report: Error starting the server:", error);
39
+ }
40
+ }
41
+ function openBrowser(url) {
42
+ const platform = process.platform;
43
+ let command;
44
+ try {
45
+ if (platform === "win32") {
46
+ command = "cmd";
47
+ spawn(command, ["/c", "start", url]);
48
+ }
49
+ else if (platform === "darwin") {
50
+ command = "open";
51
+ spawn(command, [url]);
52
+ }
53
+ else {
54
+ command = "xdg-open";
55
+ spawn(command, [url]);
56
+ }
57
+ }
58
+ catch (error) {
59
+ console.error("Ortoni Report: Error opening the browser:", error);
60
+ }
61
+ }
@@ -0,0 +1,3 @@
1
+ import { OrtoniReportConfig } from "../types/reporterConfig";
2
+ import { TestResultData } from "../types/testResults";
3
+ export declare function groupResults(config: OrtoniReportConfig, results: TestResultData[]): any;
@@ -0,0 +1,30 @@
1
+ export function groupResults(config, results) {
2
+ if (config.showProject) {
3
+ // Group by filePath, suite, and projectName
4
+ const groupedResults = results.reduce((acc, result, index) => {
5
+ const testId = `${result.filePath}:${result.projectName}:${result.title}`;
6
+ const key = `${testId}-${result.key}-${result.retryAttemptCount}`;
7
+ const { filePath, suite, projectName } = result;
8
+ acc[filePath] = acc[filePath] || {};
9
+ acc[filePath][suite] = acc[filePath][suite] || {};
10
+ acc[filePath][suite][projectName] =
11
+ acc[filePath][suite][projectName] || [];
12
+ acc[filePath][suite][projectName].push({ ...result, index, testId, key });
13
+ return acc;
14
+ }, {});
15
+ return groupedResults;
16
+ }
17
+ else {
18
+ // Group by filePath and suite, ignoring projectName
19
+ const groupedResults = results.reduce((acc, result, index) => {
20
+ const testId = `${result.filePath}:${result.projectName}:${result.title}`;
21
+ const key = `${testId}-${result.key}-${result.retryAttemptCount}`;
22
+ const { filePath, suite } = result;
23
+ acc[filePath] = acc[filePath] || {};
24
+ acc[filePath][suite] = acc[filePath][suite] || [];
25
+ acc[filePath][suite].push({ ...result, index, testId, key });
26
+ return acc;
27
+ }, {});
28
+ return groupedResults;
29
+ }
30
+ }
@@ -0,0 +1,15 @@
1
+ export declare function normalizeFilePath(filePath: string): string;
2
+ export declare function formatDate(date: Date): string;
3
+ export declare function safeStringify(obj: any, indent?: number): string;
4
+ export declare function ensureHtmlExtension(filename: string): string;
5
+ export declare function escapeHtml(unsafe: string): string;
6
+ export declare function formatDateUTC(date: Date): string;
7
+ export declare function formatDateLocal(dateInput: Date | string): string;
8
+ export declare function formatDateNoTimezone(isoString: string): string;
9
+ type SuiteAndTitle = {
10
+ hierarchy: string;
11
+ topLevelSuite: string;
12
+ parentSuite: string;
13
+ };
14
+ export declare function extractSuites(titlePath: string[]): SuiteAndTitle;
15
+ export {};
@@ -0,0 +1,84 @@
1
+ import path from "path";
2
+ export function normalizeFilePath(filePath) {
3
+ // Normalize the path to handle different separators
4
+ const normalizedPath = path.normalize(filePath);
5
+ // Get the base name of the file (removes any leading directories)
6
+ return path.basename(normalizedPath);
7
+ }
8
+ export function formatDate(date) {
9
+ const day = String(date.getDate()).padStart(2, "0");
10
+ const month = date.toLocaleString("default", { month: "short" });
11
+ const year = date.getFullYear();
12
+ const time = date.toLocaleTimeString();
13
+ return `${day}-${month}-${year} ${time}`;
14
+ }
15
+ export function safeStringify(obj, indent = 2) {
16
+ const cache = new Set();
17
+ const json = JSON.stringify(obj, (key, value) => {
18
+ if (typeof value === "object" && value !== null) {
19
+ if (cache.has(value)) {
20
+ return;
21
+ }
22
+ cache.add(value);
23
+ }
24
+ return value;
25
+ }, indent);
26
+ cache.clear();
27
+ return json;
28
+ }
29
+ export function ensureHtmlExtension(filename) {
30
+ const ext = path.extname(filename);
31
+ if (ext && ext.toLowerCase() === ".html") {
32
+ return filename;
33
+ }
34
+ return `${filename}.html`;
35
+ }
36
+ export function escapeHtml(unsafe) {
37
+ if (typeof unsafe !== "string") {
38
+ return String(unsafe);
39
+ }
40
+ return unsafe.replace(/[&<"']/g, function (match) {
41
+ const escapeMap = {
42
+ "&": "&amp;",
43
+ "<": "&lt;",
44
+ ">": "&gt;",
45
+ '"': "&quot;",
46
+ "'": "&#039;",
47
+ };
48
+ return escapeMap[match] || match;
49
+ });
50
+ }
51
+ export function formatDateUTC(date) {
52
+ return date.toISOString();
53
+ }
54
+ export function formatDateLocal(dateInput) {
55
+ const date = typeof dateInput === "string" ? new Date(dateInput) : dateInput;
56
+ const options = {
57
+ year: "numeric",
58
+ month: "short",
59
+ day: "2-digit",
60
+ hour: "2-digit",
61
+ minute: "2-digit",
62
+ hour12: true,
63
+ timeZoneName: "short", // or "Asia/Kolkata"
64
+ };
65
+ return new Intl.DateTimeFormat(undefined, options).format(date);
66
+ }
67
+ export function formatDateNoTimezone(isoString) {
68
+ const date = new Date(isoString);
69
+ return date.toLocaleString("en-US", {
70
+ dateStyle: "medium",
71
+ timeStyle: "short",
72
+ });
73
+ }
74
+ export function extractSuites(titlePath) {
75
+ const tagPattern = /@[\w]+/g;
76
+ const suiteParts = titlePath
77
+ .slice(3, titlePath.length - 1)
78
+ .map((p) => p.replace(tagPattern, "").trim());
79
+ return {
80
+ hierarchy: suiteParts.join(" > "),
81
+ topLevelSuite: suiteParts[0] ?? "",
82
+ parentSuite: suiteParts[suiteParts.length - 1] ?? "", // last suite
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ortoni-report",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "description": "Playwright Report By LetCode with Koushik",
5
5
  "scripts": {
6
6
  "tsc": "tsc",
package/readme.md CHANGED
@@ -201,7 +201,7 @@ Developed and designed by [Koushik Chatterjee](https://letcode.in/contact)
201
201
  **Tech Stack**
202
202
 
203
203
  1. Report generated using Playwright custom report
204
- 2. UI - React and Shadcn UI
204
+ 2. [UI - React and Shadcn UI](https://github.com/ortoniKC/ortoni-report-react)
205
205
  3. DB - sqlite
206
206
  4. Local host - express
207
207