Skip to content

Commit f3e6c5c

Browse files
authored
feat: implement API for downloading and reading reports (#765)
* feat: implement API for downloading and reading reports
1 parent 045150e commit f3e6c5c

6 files changed

Lines changed: 308 additions & 23 deletions

File tree

lib/common-utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export const isUrl = (str: string): boolean => {
196196
return !!parsedUrl.host && !!parsedUrl.protocol;
197197
};
198198

199-
export const fetchFile = async <T = unknown>(url: string, options?: AxiosRequestConfig) : Promise<{data: T | null, status: number}> => {
199+
export const fetchFile = async <T = unknown>(url: string, options?: AxiosRequestConfig) : Promise<{data: T | null, status: number | string, error?: unknown}> => {
200200
const {default: axios} = await import('axios');
201201

202202
try {
@@ -209,7 +209,7 @@ export const fetchFile = async <T = unknown>(url: string, options?: AxiosRequest
209209
// 'unknown' for request blocked by CORS policy
210210
const status = e.response ? e.response.status : 'unknown';
211211

212-
return {data: null, status};
212+
return {data: null, status, error: e};
213213
}
214214
};
215215

lib/db-utils/common.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,36 @@ export interface DbDetails {
3030
success: boolean;
3131
}
3232

33+
type DbJsonLoadResult = {data: DbUrlsJsonData | null; status?: string; error?: unknown};
34+
3335
export interface HandleDatabasesOptions {
34-
pluginConfig: ReporterConfig;
35-
loadDbJsonUrl: (dbJsonUrl: string) => Promise<{data: DbUrlsJsonData | null; status?: string}>;
36+
pluginConfig: Pick<ReporterConfig, 'path'>;
37+
strict?: boolean;
38+
loadDbJsonUrl: (dbJsonUrl: string) => Promise<DbJsonLoadResult>;
3639
formatData?: (dbJsonUrl: string, status?: string) => DbLoadResult;
3740
prepareUrls: (dbUrls: string[], baseUrls: string) => string[];
3841
loadDbUrl: (dbUrl: string, opts: HandleDatabasesOptions) => Promise<DbLoadResult | string>;
3942
}
4043

44+
export const makeFileDownloadErrorMessage = (fileUrl: string, error: unknown, requestStatus?: string): string => {
45+
const REMOTE_REPORT_DOWNLOAD_HINT = 'Check that the URL is correct and can be accessed without authentication (authentication during download is not available yet). Alternatively, you can download the report manually first before working with it.';
46+
47+
let reason: string;
48+
if (error instanceof Error && error.message) {
49+
reason = error.message;
50+
} else if (error && typeof error !== 'object') {
51+
reason = String(error);
52+
} else {
53+
reason = requestStatus ? `request failed with status ${requestStatus}` : 'unknown error';
54+
}
55+
56+
return [
57+
`Cannot download file from "${fileUrl}".`,
58+
`Reason: ${reason}.`,
59+
REMOTE_REPORT_DOWNLOAD_HINT
60+
].join('\n');
61+
};
62+
4163
export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabasesOptions): Promise<(string | DbLoadResult)[]> => {
4264
return _.flattenDeep(
4365
await Promise.all(
@@ -46,7 +68,13 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase
4668
const currentJsonResponse = await opts.loadDbJsonUrl(dbJsonUrl);
4769

4870
if (!currentJsonResponse.data) {
49-
logger.warn(`Cannot get data from ${dbJsonUrl}`);
71+
const message = makeFileDownloadErrorMessage(dbJsonUrl, currentJsonResponse.error, currentJsonResponse.status);
72+
73+
if (opts.strict) {
74+
throw new Error(message);
75+
}
76+
77+
logger.warn(message);
5078

5179
return opts.formatData ? opts.formatData(dbJsonUrl, currentJsonResponse.status) : [];
5280
}
@@ -60,6 +88,10 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase
6088
...preparedDbUrls.map((dbUrl: string) => opts.loadDbUrl(dbUrl, opts))
6189
]);
6290
} catch (e) {
91+
if (opts.strict) {
92+
throw e;
93+
}
94+
6395
logger.warn(`Error while downloading databases from ${dbJsonUrl}`, e);
6496

6597
return opts.formatData ? opts.formatData(dbJsonUrl) : [];

lib/db-utils/server.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from 'path';
22
import crypto from 'crypto';
33
import fs from 'fs-extra';
4+
import {pipeline} from 'stream/promises';
45
import type {Database} from '@gemini-testing/sql.js';
56
import chalk from 'chalk';
67
import NestedError from 'nested-error-stacks';
@@ -9,7 +10,7 @@ import {StaticTestsTreeBuilder} from '../tests-tree-builder/static';
910
import * as commonSqliteUtils from './common';
1011
import {isUrl, fetchFile, normalizeUrls, logger} from '../common-utils';
1112
import {DATABASE_URLS_JSON_NAME, DB_COLUMNS, LOCAL_DATABASE_NAME, TestStatus} from '../constants';
12-
import {DbLoadResult, HandleDatabasesOptions} from './common';
13+
import {DbLoadResult} from './common';
1314
import {DbUrlsJsonData, RawSuitesRow, ReporterConfig} from '../types';
1415
import {Tree} from '../tests-tree-builder/base';
1516
import {ReporterTestResult} from '../adapters/test-result';
@@ -41,7 +42,43 @@ export const prepareUrls = (urls: string[], baseUrl: string): string[] => {
4142
: urls.map(u => isUrl(u) ? u : path.join(path.parse(baseUrl).dir, u));
4243
};
4344

44-
export async function downloadDatabases(dbJsonUrls: string[], opts: HandleDatabasesOptions): Promise<(string | DbLoadResult)[]> {
45+
export interface DownloadDatabasesOptions {
46+
pluginConfig: Pick<ReporterConfig, 'path'>;
47+
strict?: boolean;
48+
}
49+
50+
export const resolveDatabaseUrlsJsonPath = async (reportPathOrUrl: string): Promise<string> => {
51+
if (isUrl(reportPathOrUrl)) {
52+
const reportUrl = new URL(reportPathOrUrl);
53+
54+
if (path.basename(reportUrl.pathname) === DATABASE_URLS_JSON_NAME) {
55+
return reportUrl.href;
56+
}
57+
58+
if (!path.extname(reportUrl.pathname) && !reportUrl.pathname.endsWith('/')) {
59+
reportUrl.pathname += '/';
60+
}
61+
62+
reportUrl.search = '';
63+
reportUrl.hash = '';
64+
65+
return new URL(DATABASE_URLS_JSON_NAME, reportUrl).href;
66+
}
67+
68+
const resolvedReportPath = path.resolve(reportPathOrUrl);
69+
70+
if (path.basename(resolvedReportPath) === DATABASE_URLS_JSON_NAME) {
71+
return resolvedReportPath;
72+
}
73+
74+
const stat = await fs.stat(resolvedReportPath);
75+
76+
return stat.isDirectory()
77+
? path.join(resolvedReportPath, DATABASE_URLS_JSON_NAME)
78+
: path.join(path.dirname(resolvedReportPath), DATABASE_URLS_JSON_NAME);
79+
};
80+
81+
export async function downloadDatabases(dbJsonUrls: string[], opts: DownloadDatabasesOptions): Promise<(string | DbLoadResult)[]> {
4582
const loadDbJsonUrl = async (dbJsonUrl: string): Promise<{data: DbUrlsJsonData | null}> => {
4683
if (isUrl(dbJsonUrl)) {
4784
return fetchFile(dbJsonUrl);
@@ -51,7 +88,7 @@ export async function downloadDatabases(dbJsonUrls: string[], opts: HandleDataba
5188
return {data};
5289
};
5390

54-
const loadDbUrl = (dbUrl: string, opts: HandleDatabasesOptions): Promise<string> => downloadSingleDatabase(dbUrl, opts);
91+
const loadDbUrl = (dbUrl: string, opts: DownloadDatabasesOptions): Promise<string> => downloadSingleDatabase(dbUrl, opts);
5592

5693
return commonSqliteUtils.handleDatabases(dbJsonUrls, {...opts, loadDbJsonUrl, prepareUrls, loadDbUrl});
5794
}
@@ -119,7 +156,7 @@ export async function getTestsTreeFromDatabase(dbPath: string, baseHost: string)
119156
}
120157
}
121158

122-
async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConfig: ReporterConfig}): Promise<string> {
159+
export async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConfig: Pick<ReporterConfig, 'path'>}): Promise<string> {
123160
if (!isUrl(dbUrl)) {
124161
return path.resolve(pluginConfig.path, dbUrl);
125162
}
@@ -128,20 +165,17 @@ async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConf
128165

129166
logger.log(chalk.green(`Download ${dbUrl} to ${pluginConfig.path}`));
130167

131-
const {default: axios} = await import('axios');
132-
const response = await axios({
133-
url: dbUrl,
134-
responseType: 'stream'
135-
});
136-
137-
const writer = fs.createWriteStream(dest);
138-
139-
response.data.pipe(writer);
140-
141-
await new Promise((resolve, reject) => {
142-
writer.on('finish', resolve);
143-
writer.on('error', reject);
144-
});
168+
try {
169+
const {default: axios} = await import('axios');
170+
const response = await axios({
171+
url: dbUrl,
172+
responseType: 'stream'
173+
});
174+
175+
await pipeline(response.data, fs.createWriteStream(dest));
176+
} catch (err) {
177+
throw new Error(commonSqliteUtils.makeFileDownloadErrorMessage(dbUrl, err));
178+
}
145179

146180
return dest;
147181
}

lib/sdk.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import fs from 'fs-extra';
2+
import path from 'path';
3+
4+
import {
5+
downloadDatabases,
6+
makeSqlDatabaseFromFile,
7+
prepareUrls,
8+
resolveDatabaseUrlsJsonPath,
9+
downloadSingleDatabase
10+
} from './db-utils/server';
11+
import {compareDatabaseRowsByTimestamp, selectAllSuitesQuery} from './db-utils/common';
12+
import {ReporterTestResult} from './adapters/test-result';
13+
import {SqliteTestResultAdapter} from './adapters/test-result/sqlite';
14+
import {RawSuitesRow} from './types';
15+
import {DB_COLUMN_INDEXES} from './constants';
16+
import {isUrl} from './common-utils';
17+
import {isDbFile, writeDatabaseUrlsFile} from './server-utils';
18+
19+
export type {ReporterTestResult};
20+
21+
export type ReportFileToDownload = 'dbFiles';
22+
23+
export interface DownloadReportOptions {
24+
files?: ReportFileToDownload[];
25+
}
26+
27+
export interface DownloadReportResult {
28+
reportPath: string;
29+
dbPaths: string[];
30+
}
31+
32+
const DEFAULT_REPORT_FILES_TO_DOWNLOAD: ReportFileToDownload[] = ['dbFiles'];
33+
34+
const isString = (value: unknown): value is string => typeof value === 'string';
35+
36+
const resolveAttempt = (attemptsByBrowser: Map<string, number>, row: RawSuitesRow): number => {
37+
const testPath: string[] = JSON.parse(row[DB_COLUMN_INDEXES.suitePath] as string);
38+
const browserName = row[DB_COLUMN_INDEXES.name] as string;
39+
const browserId = [...testPath, browserName].join(' ');
40+
const attempt = attemptsByBrowser.has(browserId) ? (attemptsByBrowser.get(browserId) as number) + 1 : 0;
41+
42+
attemptsByBrowser.set(browserId, attempt);
43+
44+
return attempt;
45+
};
46+
47+
const validateReportFilesToDownload = (files: ReportFileToDownload[]): void => {
48+
for (const file of files) {
49+
if (file !== 'dbFiles') {
50+
throw new Error(`Unsupported report file type to download: ${file}`);
51+
}
52+
}
53+
};
54+
55+
const validateLocalDatabaseUrlsJson = async (dbJsonPath: string): Promise<void> => {
56+
const {dbUrls = [], jsonUrls = []} = await fs.readJSON(dbJsonPath);
57+
const preparedDbUrls = prepareUrls(dbUrls, dbJsonPath);
58+
const preparedDbJsonUrls = prepareUrls(jsonUrls, dbJsonPath);
59+
60+
[...preparedDbUrls, ...preparedDbJsonUrls].forEach((filePathOrUrl) => {
61+
if (isUrl(filePathOrUrl)) {
62+
throw new Error(`Cannot read remote report file "${filePathOrUrl}". Use downloadReport first.`);
63+
}
64+
});
65+
66+
await Promise.all(preparedDbJsonUrls.map(validateLocalDatabaseUrlsJson));
67+
};
68+
69+
const downloadDbFiles = async (reportPathOrUrl: string, destPath: string): Promise<string[]> => {
70+
if (isDbFile(reportPathOrUrl)) {
71+
return [await downloadSingleDatabase(reportPathOrUrl, {
72+
pluginConfig: {path: destPath}
73+
})];
74+
}
75+
76+
const dbJsonPathOrUrl = await resolveDatabaseUrlsJsonPath(reportPathOrUrl);
77+
const dbPaths = await downloadDatabases([dbJsonPathOrUrl], {
78+
pluginConfig: {path: destPath},
79+
strict: true
80+
});
81+
82+
return dbPaths.filter(isString);
83+
};
84+
85+
const resolveLocalDbPaths = async (reportPath: string): Promise<string[]> => {
86+
if (isUrl(reportPath)) {
87+
throw new Error('readResultsFromReport expects a local report path. Use downloadReport first for remote reports.');
88+
}
89+
90+
if (isDbFile(reportPath)) {
91+
return [path.resolve(reportPath)];
92+
}
93+
94+
const dbJsonPath = await resolveDatabaseUrlsJsonPath(reportPath);
95+
96+
await validateLocalDatabaseUrlsJson(dbJsonPath);
97+
98+
const dbPaths = await downloadDatabases([dbJsonPath], {
99+
pluginConfig: {path: path.dirname(dbJsonPath)},
100+
strict: true
101+
});
102+
103+
return dbPaths.filter(isString);
104+
};
105+
106+
export const downloadReport = async (
107+
reportPathOrUrl: string,
108+
destPath: string,
109+
options: DownloadReportOptions = {}
110+
): Promise<DownloadReportResult> => {
111+
const files = options.files ?? DEFAULT_REPORT_FILES_TO_DOWNLOAD;
112+
113+
validateReportFilesToDownload(files);
114+
115+
if (!isUrl(reportPathOrUrl)) {
116+
throw new Error('downloadReport expects a remote report path or URL. Use readResultsFromReport directly for local reports.');
117+
}
118+
119+
await fs.ensureDir(destPath);
120+
121+
const dbPaths = files.includes('dbFiles') ? await downloadDbFiles(reportPathOrUrl, destPath) : [];
122+
123+
if (files.includes('dbFiles')) {
124+
await writeDatabaseUrlsFile(destPath, dbPaths.map(dbPath => path.relative(destPath, dbPath)));
125+
}
126+
127+
return {
128+
reportPath: destPath,
129+
dbPaths
130+
};
131+
};
132+
133+
export const readResultsFromReport = async (reportPath: string): Promise<ReporterTestResult[]> => {
134+
const dbPaths = await resolveLocalDbPaths(reportPath);
135+
const rows: RawSuitesRow[] = [];
136+
const attemptsByBrowser = new Map<string, number>();
137+
const results: ReporterTestResult[] = [];
138+
139+
for (const dbPath of dbPaths) {
140+
const db = await makeSqlDatabaseFromFile(dbPath);
141+
const statement = db.prepare(selectAllSuitesQuery());
142+
143+
while (statement.step()) {
144+
rows.push(statement.get() as RawSuitesRow);
145+
}
146+
147+
statement.free();
148+
db.close();
149+
}
150+
151+
for (const row of rows.sort(compareDatabaseRowsByTimestamp)) {
152+
const attempt = resolveAttempt(attemptsByBrowser, row);
153+
154+
results.push(new SqliteTestResultAdapter(row, attempt));
155+
}
156+
157+
return results;
158+
};

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"plugins-sdk/build-ui"
99
],
1010
"exports": {
11+
"./experimental/sdk": {
12+
"types": "./build/lib/sdk.d.ts",
13+
"default": "./build/lib/sdk.js"
14+
},
1115
"./testplane": "./build/testplane.js",
1216
"./hermione": "./build/hermione.js",
1317
"./playwright": "./build/playwright.js",

0 commit comments

Comments
 (0)