From fb9f6b2e550dde36e64f0cb2720c6ceb61b8d0df Mon Sep 17 00:00:00 2001 From: shadowusr Date: Mon, 11 May 2026 23:44:41 +0300 Subject: [PATCH 1/2] feat: implement API for downloading and reading reports --- lib/db-utils/common.ts | 11 ++- lib/db-utils/server.ts | 44 ++++++++++-- lib/sdk.ts | 158 +++++++++++++++++++++++++++++++++++++++++ package.json | 4 ++ 4 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 lib/sdk.ts diff --git a/lib/db-utils/common.ts b/lib/db-utils/common.ts index e4b96c890..309691911 100644 --- a/lib/db-utils/common.ts +++ b/lib/db-utils/common.ts @@ -31,7 +31,8 @@ export interface DbDetails { } export interface HandleDatabasesOptions { - pluginConfig: ReporterConfig; + pluginConfig: Pick; + strict?: boolean; loadDbJsonUrl: (dbJsonUrl: string) => Promise<{data: DbUrlsJsonData | null; status?: string}>; formatData?: (dbJsonUrl: string, status?: string) => DbLoadResult; prepareUrls: (dbUrls: string[], baseUrls: string) => string[]; @@ -46,6 +47,10 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase const currentJsonResponse = await opts.loadDbJsonUrl(dbJsonUrl); if (!currentJsonResponse.data) { + if (opts.strict) { + throw new Error(`Cannot get data from ${dbJsonUrl}`); + } + logger.warn(`Cannot get data from ${dbJsonUrl}`); return opts.formatData ? opts.formatData(dbJsonUrl, currentJsonResponse.status) : []; @@ -60,6 +65,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..d529c9d6b 100644 --- a/lib/db-utils/server.ts +++ b/lib/db-utils/server.ts @@ -9,7 +9,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 +41,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 +87,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 +155,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); } 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", From 04ddffc96de651d8b5d69af4ea32a96c74662795 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Tue, 12 May 2026 14:11:57 +0300 Subject: [PATCH 2/2] fix: provide detailed error messages when report download failed --- lib/common-utils.ts | 4 +-- lib/db-utils/common.ts | 29 ++++++++++++++-- lib/db-utils/server.ts | 26 +++++++-------- test/unit/lib/db-utils/common.js | 57 ++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 test/unit/lib/db-utils/common.js 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 309691911..502c33d05 100644 --- a/lib/db-utils/common.ts +++ b/lib/db-utils/common.ts @@ -30,15 +30,36 @@ export interface DbDetails { success: boolean; } +type DbJsonLoadResult = {data: DbUrlsJsonData | null; status?: string; error?: unknown}; + export interface HandleDatabasesOptions { pluginConfig: Pick; strict?: boolean; - loadDbJsonUrl: (dbJsonUrl: string) => Promise<{data: DbUrlsJsonData | null; status?: string}>; + 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( @@ -47,11 +68,13 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase const currentJsonResponse = await opts.loadDbJsonUrl(dbJsonUrl); if (!currentJsonResponse.data) { + const message = makeFileDownloadErrorMessage(dbJsonUrl, currentJsonResponse.error, currentJsonResponse.status); + if (opts.strict) { - throw new Error(`Cannot get data from ${dbJsonUrl}`); + throw new Error(message); } - logger.warn(`Cannot get data from ${dbJsonUrl}`); + logger.warn(message); return opts.formatData ? opts.formatData(dbJsonUrl, currentJsonResponse.status) : []; } diff --git a/lib/db-utils/server.ts b/lib/db-utils/server.ts index d529c9d6b..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'; @@ -164,20 +165,17 @@ export async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {plu 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/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'); + }); + }); +});