Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/common-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export const isUrl = (str: string): boolean => {
return !!parsedUrl.host && !!parsedUrl.protocol;
};

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

try {
Expand All @@ -209,7 +209,7 @@ export const fetchFile = async <T = unknown>(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};
}
};

Expand Down
38 changes: 35 additions & 3 deletions lib/db-utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReporterConfig, 'path'>;
strict?: boolean;
loadDbJsonUrl: (dbJsonUrl: string) => Promise<DbJsonLoadResult>;
formatData?: (dbJsonUrl: string, status?: string) => DbLoadResult;
prepareUrls: (dbUrls: string[], baseUrls: string) => string[];
loadDbUrl: (dbUrl: string, opts: HandleDatabasesOptions) => Promise<DbLoadResult | string>;
}

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(
Expand 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) : [];
}
Expand All @@ -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) : [];
Expand Down
70 changes: 52 additions & 18 deletions lib/db-utils/server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<ReporterConfig, 'path'>;
strict?: boolean;
}

export const resolveDatabaseUrlsJsonPath = async (reportPathOrUrl: string): Promise<string> => {
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);
Expand All @@ -51,7 +88,7 @@ export async function downloadDatabases(dbJsonUrls: string[], opts: HandleDataba
return {data};
};

const loadDbUrl = (dbUrl: string, opts: HandleDatabasesOptions): Promise<string> => downloadSingleDatabase(dbUrl, opts);
const loadDbUrl = (dbUrl: string, opts: DownloadDatabasesOptions): Promise<string> => downloadSingleDatabase(dbUrl, opts);

return commonSqliteUtils.handleDatabases(dbJsonUrls, {...opts, loadDbJsonUrl, prepareUrls, loadDbUrl});
}
Expand Down Expand Up @@ -119,7 +156,7 @@ export async function getTestsTreeFromDatabase(dbPath: string, baseHost: string)
}
}

async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConfig: ReporterConfig}): Promise<string> {
export async function downloadSingleDatabase(dbUrl: string, {pluginConfig}: {pluginConfig: Pick<ReporterConfig, 'path'>}): Promise<string> {
if (!isUrl(dbUrl)) {
return path.resolve(pluginConfig.path, dbUrl);
}
Expand All @@ -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;
}
Expand Down
158 changes: 158 additions & 0 deletions lib/sdk.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>, 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(' ');
Comment thread
shadowusr marked this conversation as resolved.
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<void> => {
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<string[]> => {
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<string[]> => {
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<DownloadReportResult> => {
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<ReporterTestResult[]> => {
const dbPaths = await resolveLocalDbPaths(reportPath);
const rows: RawSuitesRow[] = [];
const attemptsByBrowser = new Map<string, number>();
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;
};
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading