diff --git a/lib/common-utils.ts b/lib/common-utils.ts index 2782dda94..7e0f09053 100644 --- a/lib/common-utils.ts +++ b/lib/common-utils.ts @@ -196,7 +196,7 @@ export const isUrl = (str: string): boolean => { return !!parsedUrl.host && !!parsedUrl.protocol; }; -export const fetchFile = async (url: string, options?: AxiosRequestConfig) : Promise<{data: T | null, status: number}> => { +export const fetchFile = async (url: string, options?: AxiosRequestConfig) : Promise<{data: T | null, status: number | string, error?: unknown}> => { const {default: axios} = await import('axios'); try { @@ -209,7 +209,7 @@ export const fetchFile = async (url: string, options?: AxiosRequest // 'unknown' for request blocked by CORS policy const status = e.response ? e.response.status : 'unknown'; - return {data: null, status}; + return {data: null, status, error: e}; } }; diff --git a/lib/db-utils/common.ts b/lib/db-utils/common.ts index e4b96c890..502c33d05 100644 --- a/lib/db-utils/common.ts +++ b/lib/db-utils/common.ts @@ -30,14 +30,36 @@ export interface DbDetails { success: boolean; } +type DbJsonLoadResult = {data: DbUrlsJsonData | null; status?: string; error?: unknown}; + export interface HandleDatabasesOptions { - pluginConfig: ReporterConfig; - loadDbJsonUrl: (dbJsonUrl: string) => Promise<{data: DbUrlsJsonData | null; status?: string}>; + pluginConfig: Pick; + strict?: boolean; + loadDbJsonUrl: (dbJsonUrl: string) => Promise; formatData?: (dbJsonUrl: string, status?: string) => DbLoadResult; prepareUrls: (dbUrls: string[], baseUrls: string) => string[]; loadDbUrl: (dbUrl: string, opts: HandleDatabasesOptions) => Promise; } +export const makeFileDownloadErrorMessage = (fileUrl: string, error: unknown, requestStatus?: string): string => { + 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.'; + + let reason: string; + if (error instanceof Error && error.message) { + reason = error.message; + } else if (error && typeof error !== 'object') { + reason = String(error); + } else { + reason = requestStatus ? `request failed with status ${requestStatus}` : 'unknown error'; + } + + return [ + `Cannot download file from "${fileUrl}".`, + `Reason: ${reason}.`, + REMOTE_REPORT_DOWNLOAD_HINT + ].join('\n'); +}; + export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabasesOptions): Promise<(string | DbLoadResult)[]> => { return _.flattenDeep( await Promise.all( @@ -46,7 +68,13 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase const currentJsonResponse = await opts.loadDbJsonUrl(dbJsonUrl); if (!currentJsonResponse.data) { - logger.warn(`Cannot get data from ${dbJsonUrl}`); + const message = makeFileDownloadErrorMessage(dbJsonUrl, currentJsonResponse.error, currentJsonResponse.status); + + if (opts.strict) { + throw new Error(message); + } + + logger.warn(message); return opts.formatData ? opts.formatData(dbJsonUrl, currentJsonResponse.status) : []; } @@ -60,6 +88,10 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase ...preparedDbUrls.map((dbUrl: string) => opts.loadDbUrl(dbUrl, opts)) ]); } catch (e) { + if (opts.strict) { + throw e; + } + logger.warn(`Error while downloading databases from ${dbJsonUrl}`, e); return opts.formatData ? opts.formatData(dbJsonUrl) : []; diff --git a/lib/db-utils/server.ts b/lib/db-utils/server.ts index 350b9d2c6..5f4f43fb9 100644 --- a/lib/db-utils/server.ts +++ b/lib/db-utils/server.ts @@ -1,6 +1,7 @@ import path from 'path'; import crypto from 'crypto'; import fs from 'fs-extra'; +import {pipeline} from 'stream/promises'; import type {Database} from '@gemini-testing/sql.js'; import chalk from 'chalk'; import NestedError from 'nested-error-stacks'; @@ -9,7 +10,7 @@ import {StaticTestsTreeBuilder} from '../tests-tree-builder/static'; import * as commonSqliteUtils from './common'; import {isUrl, fetchFile, normalizeUrls, logger} from '../common-utils'; import {DATABASE_URLS_JSON_NAME, DB_COLUMNS, LOCAL_DATABASE_NAME, TestStatus} from '../constants'; -import {DbLoadResult, HandleDatabasesOptions} from './common'; +import {DbLoadResult} from './common'; import {DbUrlsJsonData, RawSuitesRow, ReporterConfig} from '../types'; import {Tree} from '../tests-tree-builder/base'; import {ReporterTestResult} from '../adapters/test-result'; @@ -41,7 +42,43 @@ export const prepareUrls = (urls: string[], baseUrl: string): string[] => { : urls.map(u => isUrl(u) ? u : path.join(path.parse(baseUrl).dir, u)); }; -export async function downloadDatabases(dbJsonUrls: string[], opts: HandleDatabasesOptions): Promise<(string | DbLoadResult)[]> { +export interface DownloadDatabasesOptions { + pluginConfig: Pick; + strict?: boolean; +} + +export const resolveDatabaseUrlsJsonPath = async (reportPathOrUrl: string): Promise => { + if (isUrl(reportPathOrUrl)) { + const reportUrl = new URL(reportPathOrUrl); + + if (path.basename(reportUrl.pathname) === DATABASE_URLS_JSON_NAME) { + return reportUrl.href; + } + + if (!path.extname(reportUrl.pathname) && !reportUrl.pathname.endsWith('/')) { + reportUrl.pathname += '/'; + } + + reportUrl.search = ''; + reportUrl.hash = ''; + + return new URL(DATABASE_URLS_JSON_NAME, reportUrl).href; + } + + const resolvedReportPath = path.resolve(reportPathOrUrl); + + if (path.basename(resolvedReportPath) === DATABASE_URLS_JSON_NAME) { + return resolvedReportPath; + } + + const stat = await fs.stat(resolvedReportPath); + + return stat.isDirectory() + ? path.join(resolvedReportPath, DATABASE_URLS_JSON_NAME) + : path.join(path.dirname(resolvedReportPath), DATABASE_URLS_JSON_NAME); +}; + +export async function downloadDatabases(dbJsonUrls: string[], opts: DownloadDatabasesOptions): Promise<(string | DbLoadResult)[]> { const loadDbJsonUrl = async (dbJsonUrl: string): Promise<{data: DbUrlsJsonData | null}> => { if (isUrl(dbJsonUrl)) { return fetchFile(dbJsonUrl); @@ -51,7 +88,7 @@ export async function downloadDatabases(dbJsonUrls: string[], opts: HandleDataba return {data}; }; - const loadDbUrl = (dbUrl: string, opts: HandleDatabasesOptions): Promise => downloadSingleDatabase(dbUrl, opts); + const loadDbUrl = (dbUrl: string, opts: DownloadDatabasesOptions): Promise => downloadSingleDatabase(dbUrl, opts); return commonSqliteUtils.handleDatabases(dbJsonUrls, {...opts, loadDbJsonUrl, prepareUrls, loadDbUrl}); } @@ -119,7 +156,7 @@ export async function getTestsTreeFromDatabase(dbPath: string, baseHost: string) } } -async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConfig: ReporterConfig}): Promise { +export async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConfig: Pick}): Promise { if (!isUrl(dbUrl)) { return path.resolve(pluginConfig.path, dbUrl); } @@ -128,20 +165,17 @@ async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConf logger.log(chalk.green(`Download ${dbUrl} to ${pluginConfig.path}`)); - const {default: axios} = await import('axios'); - const response = await axios({ - url: dbUrl, - responseType: 'stream' - }); - - const writer = fs.createWriteStream(dest); - - response.data.pipe(writer); - - await new Promise((resolve, reject) => { - writer.on('finish', resolve); - writer.on('error', reject); - }); + try { + const {default: axios} = await import('axios'); + const response = await axios({ + url: dbUrl, + responseType: 'stream' + }); + + await pipeline(response.data, fs.createWriteStream(dest)); + } catch (err) { + throw new Error(commonSqliteUtils.makeFileDownloadErrorMessage(dbUrl, err)); + } return dest; } diff --git a/lib/sdk.ts b/lib/sdk.ts new file mode 100644 index 000000000..3ea17319c --- /dev/null +++ b/lib/sdk.ts @@ -0,0 +1,158 @@ +import fs from 'fs-extra'; +import path from 'path'; + +import { + downloadDatabases, + makeSqlDatabaseFromFile, + prepareUrls, + resolveDatabaseUrlsJsonPath, + downloadSingleDatabase +} from './db-utils/server'; +import {compareDatabaseRowsByTimestamp, selectAllSuitesQuery} from './db-utils/common'; +import {ReporterTestResult} from './adapters/test-result'; +import {SqliteTestResultAdapter} from './adapters/test-result/sqlite'; +import {RawSuitesRow} from './types'; +import {DB_COLUMN_INDEXES} from './constants'; +import {isUrl} from './common-utils'; +import {isDbFile, writeDatabaseUrlsFile} from './server-utils'; + +export type {ReporterTestResult}; + +export type ReportFileToDownload = 'dbFiles'; + +export interface DownloadReportOptions { + files?: ReportFileToDownload[]; +} + +export interface DownloadReportResult { + reportPath: string; + dbPaths: string[]; +} + +const DEFAULT_REPORT_FILES_TO_DOWNLOAD: ReportFileToDownload[] = ['dbFiles']; + +const isString = (value: unknown): value is string => typeof value === 'string'; + +const resolveAttempt = (attemptsByBrowser: Map, row: RawSuitesRow): number => { + const testPath: string[] = JSON.parse(row[DB_COLUMN_INDEXES.suitePath] as string); + const browserName = row[DB_COLUMN_INDEXES.name] as string; + const browserId = [...testPath, browserName].join(' '); + const attempt = attemptsByBrowser.has(browserId) ? (attemptsByBrowser.get(browserId) as number) + 1 : 0; + + attemptsByBrowser.set(browserId, attempt); + + return attempt; +}; + +const validateReportFilesToDownload = (files: ReportFileToDownload[]): void => { + for (const file of files) { + if (file !== 'dbFiles') { + throw new Error(`Unsupported report file type to download: ${file}`); + } + } +}; + +const validateLocalDatabaseUrlsJson = async (dbJsonPath: string): Promise => { + const {dbUrls = [], jsonUrls = []} = await fs.readJSON(dbJsonPath); + const preparedDbUrls = prepareUrls(dbUrls, dbJsonPath); + const preparedDbJsonUrls = prepareUrls(jsonUrls, dbJsonPath); + + [...preparedDbUrls, ...preparedDbJsonUrls].forEach((filePathOrUrl) => { + if (isUrl(filePathOrUrl)) { + throw new Error(`Cannot read remote report file "${filePathOrUrl}". Use downloadReport first.`); + } + }); + + await Promise.all(preparedDbJsonUrls.map(validateLocalDatabaseUrlsJson)); +}; + +const downloadDbFiles = async (reportPathOrUrl: string, destPath: string): Promise => { + if (isDbFile(reportPathOrUrl)) { + return [await downloadSingleDatabase(reportPathOrUrl, { + pluginConfig: {path: destPath} + })]; + } + + const dbJsonPathOrUrl = await resolveDatabaseUrlsJsonPath(reportPathOrUrl); + const dbPaths = await downloadDatabases([dbJsonPathOrUrl], { + pluginConfig: {path: destPath}, + strict: true + }); + + return dbPaths.filter(isString); +}; + +const resolveLocalDbPaths = async (reportPath: string): Promise => { + if (isUrl(reportPath)) { + throw new Error('readResultsFromReport expects a local report path. Use downloadReport first for remote reports.'); + } + + if (isDbFile(reportPath)) { + return [path.resolve(reportPath)]; + } + + const dbJsonPath = await resolveDatabaseUrlsJsonPath(reportPath); + + await validateLocalDatabaseUrlsJson(dbJsonPath); + + const dbPaths = await downloadDatabases([dbJsonPath], { + pluginConfig: {path: path.dirname(dbJsonPath)}, + strict: true + }); + + return dbPaths.filter(isString); +}; + +export const downloadReport = async ( + reportPathOrUrl: string, + destPath: string, + options: DownloadReportOptions = {} +): Promise => { + const files = options.files ?? DEFAULT_REPORT_FILES_TO_DOWNLOAD; + + validateReportFilesToDownload(files); + + if (!isUrl(reportPathOrUrl)) { + throw new Error('downloadReport expects a remote report path or URL. Use readResultsFromReport directly for local reports.'); + } + + await fs.ensureDir(destPath); + + const dbPaths = files.includes('dbFiles') ? await downloadDbFiles(reportPathOrUrl, destPath) : []; + + if (files.includes('dbFiles')) { + await writeDatabaseUrlsFile(destPath, dbPaths.map(dbPath => path.relative(destPath, dbPath))); + } + + return { + reportPath: destPath, + dbPaths + }; +}; + +export const readResultsFromReport = async (reportPath: string): Promise => { + const dbPaths = await resolveLocalDbPaths(reportPath); + const rows: RawSuitesRow[] = []; + const attemptsByBrowser = new Map(); + const results: ReporterTestResult[] = []; + + for (const dbPath of dbPaths) { + const db = await makeSqlDatabaseFromFile(dbPath); + const statement = db.prepare(selectAllSuitesQuery()); + + while (statement.step()) { + rows.push(statement.get() as RawSuitesRow); + } + + statement.free(); + db.close(); + } + + for (const row of rows.sort(compareDatabaseRowsByTimestamp)) { + const attempt = resolveAttempt(attemptsByBrowser, row); + + results.push(new SqliteTestResultAdapter(row, attempt)); + } + + return results; +}; diff --git a/package.json b/package.json index 37a5f987e..09c302076 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "plugins-sdk/build-ui" ], "exports": { + "./experimental/sdk": { + "types": "./build/lib/sdk.d.ts", + "default": "./build/lib/sdk.js" + }, "./testplane": "./build/testplane.js", "./hermione": "./build/hermione.js", "./playwright": "./build/playwright.js", diff --git a/test/unit/lib/db-utils/common.js b/test/unit/lib/db-utils/common.js new file mode 100644 index 000000000..414cdc302 --- /dev/null +++ b/test/unit/lib/db-utils/common.js @@ -0,0 +1,57 @@ +'use strict'; + +const {handleDatabases, makeDbFileDownloadErrorMessage} = require('lib/db-utils/common'); + +describe('lib/db-utils/common', () => { + const sandbox = sinon.sandbox.create(); + + afterEach(() => sandbox.restore()); + + const mkOpts_ = (loadDbJsonUrl) => ({ + pluginConfig: {path: '/report'}, + loadDbJsonUrl, + prepareUrls: sandbox.stub().returns([]), + loadDbUrl: sandbox.stub().resolves('/report/sqlite.db') + }); + + describe('handleDatabases', () => { + it('should throw detailed error with download reason in strict mode', async () => { + const error = new Error('Request failed with status code 403'); + const loadDbJsonUrl = sandbox.stub().resolves({data: null, status: 403, error}); + + const promise = handleDatabases(['https://example.com/report/databaseUrls.json'], { + ...mkOpts_(loadDbJsonUrl), + strict: true + }); + + let err; + try { + await promise; + } catch (e) { + err = e; + } + + assert.include(err.message, 'Cannot download file from "https://example.com/report/databaseUrls.json'); + assert.include(err.message, 'Request failed with status code 403'); + }); + + it('should throw detailed error with status when strict response has no data', async () => { + const loadDbJsonUrl = sandbox.stub().resolves({data: null, status: 200}); + + const promise = handleDatabases(['/report/databaseUrls.json'], { + ...mkOpts_(loadDbJsonUrl), + strict: true + }); + + let err; + try { + await promise; + } catch (e) { + err = e; + } + + assert.include(err.message, 'Cannot download file from "/report/databaseUrls.json"'); + assert.include(err.message, 'request failed with status 200'); + }); + }); +});