From 79a267c7f8dd61baab03edbeb83fd47af9875cdf Mon Sep 17 00:00:00 2001 From: Cacie Prins Date: Fri, 12 Apr 2024 09:20:54 -0400 Subject: [PATCH] refactor: extract artifact upload process from lib/modes/record.js (#29240) * refactor record.js to extract upload logic into ts - streamlines the main uploadArtifact fn - extracts artifact specific logic to artifact classes - fully defines types for upload processing and reporting * tweak refactors so system tests produce same snapshots * some todos, fixes exports/imports from api/index.ts * fix api export so it can be imported by ts files * cleans up types * extracting artifact metadata from options logs to debug but does not throw if errors are encountered * fix type imports in print-run * fix debug formatting for artifacts * fix reporting successful protocol uploads * change inheritence to strategy * rm empty file * Update packages/server/lib/cloud/artifacts/upload_artifacts.ts * makes protocolManager optional to uploadArtifacts, fixes conditional accessor in protocol fatal error report * missed a potentially undef * convert to frozen object / keyof instead of string composition for artifact kinds --------- Co-authored-by: Ryan Manuel Co-authored-by: Jennifer Shehane --- packages/server/lib/cloud/api/index.ts | 27 +- .../server/lib/cloud/artifacts/artifact.ts | 113 ++++++ .../cloud/artifacts/file_upload_strategy.ts | 6 + .../lib/cloud/artifacts/protocol_artifact.ts | 61 +++ .../cloud/artifacts/screenshot_artifact.ts | 44 +++ .../lib/cloud/artifacts/upload_artifacts.ts | 204 ++++++++++ .../lib/cloud/artifacts/video_artifact.ts | 9 + packages/server/lib/cloud/exception.ts | 2 +- packages/server/lib/cloud/protocol.ts | 15 +- packages/server/lib/cloud/upload.ts | 16 - packages/server/lib/cloud/upload/send_file.ts | 14 + packages/server/lib/cloud/user.ts | 4 +- packages/server/lib/modes/record.js | 370 +----------------- packages/server/lib/util/print-run.ts | 128 ++++-- .../server/test/integration/cypress_spec.js | 2 +- .../server/test/unit/cloud/api/api_spec.js | 4 +- .../server/test/unit/cloud/exceptions_spec.js | 2 +- packages/server/test/unit/cloud/user_spec.js | 2 +- .../server/test/unit/modes/record_spec.js | 2 +- packages/types/src/protocol.ts | 5 +- 20 files changed, 589 insertions(+), 441 deletions(-) create mode 100644 packages/server/lib/cloud/artifacts/artifact.ts create mode 100644 packages/server/lib/cloud/artifacts/file_upload_strategy.ts create mode 100644 packages/server/lib/cloud/artifacts/protocol_artifact.ts create mode 100644 packages/server/lib/cloud/artifacts/screenshot_artifact.ts create mode 100644 packages/server/lib/cloud/artifacts/upload_artifacts.ts create mode 100644 packages/server/lib/cloud/artifacts/video_artifact.ts delete mode 100644 packages/server/lib/cloud/upload.ts create mode 100644 packages/server/lib/cloud/upload/send_file.ts diff --git a/packages/server/lib/cloud/api/index.ts b/packages/server/lib/cloud/api/index.ts index 2b089ff6a223..ef4cbb5d595b 100644 --- a/packages/server/lib/cloud/api/index.ts +++ b/packages/server/lib/cloud/api/index.ts @@ -58,6 +58,7 @@ export interface CypressRequestOptions extends OptionsWithUrl { cacheable?: boolean } +// TODO: migrate to fetch from @cypress/request const rp = request.defaults((params: CypressRequestOptions, callback) => { let resp @@ -274,22 +275,23 @@ type CreateRunResponse = { } | undefined } -type ArtifactMetadata = { +export type ArtifactMetadata = { url: string - fileSize?: number + fileSize?: number | bigint uploadDuration?: number success: boolean error?: string + errorStack?: string } -type ProtocolMetadata = ArtifactMetadata & { +export type ProtocolMetadata = ArtifactMetadata & { specAccess?: { - size: bigint - offset: bigint + size: number + offset: number } } -type UpdateInstanceArtifactsPayload = { +export type UpdateInstanceArtifactsPayload = { screenshots: ArtifactMetadata[] video?: ArtifactMetadata protocol?: ProtocolMetadata @@ -298,7 +300,7 @@ type UpdateInstanceArtifactsPayload = { type UpdateInstanceArtifactsOptions = { runId: string instanceId: string - timeout: number | undefined + timeout?: number } let preflightResult = { @@ -307,7 +309,10 @@ let preflightResult = { let recordRoutes = apiRoutes -module.exports = { +// Potential todos: Refactor to named exports, refactor away from `this.` in exports, +// move individual exports to their own files & convert this to barrelfile + +export default { rp, // For internal testing @@ -400,8 +405,10 @@ module.exports = { let script try { - if (captureProtocolUrl || process.env.CYPRESS_LOCAL_PROTOCOL_PATH) { - script = await this.getCaptureProtocolScript(captureProtocolUrl || process.env.CYPRESS_LOCAL_PROTOCOL_PATH) + const protocolUrl = captureProtocolUrl || process.env.CYPRESS_LOCAL_PROTOCOL_PATH + + if (protocolUrl) { + script = await this.getCaptureProtocolScript(protocolUrl) } } catch (e) { debugProtocol('Error downloading capture code', e) diff --git a/packages/server/lib/cloud/artifacts/artifact.ts b/packages/server/lib/cloud/artifacts/artifact.ts new file mode 100644 index 000000000000..dfda66aa4bcd --- /dev/null +++ b/packages/server/lib/cloud/artifacts/artifact.ts @@ -0,0 +1,113 @@ +import Debug from 'debug' +import { performance } from 'perf_hooks' + +const debug = Debug('cypress:server:cloud:artifact') + +const isAggregateError = (err: any): err is AggregateError => { + return !!err.errors +} + +export const ArtifactKinds = Object.freeze({ + VIDEO: 'video', + SCREENSHOTS: 'screenshots', + PROTOCOL: 'protocol', +}) + +type ArtifactKind = typeof ArtifactKinds[keyof typeof ArtifactKinds] + +export interface IArtifact { + reportKey: ArtifactKind + uploadUrl: string + filePath: string + fileSize: number | bigint + upload: () => Promise +} + +export interface ArtifactUploadResult { + success: boolean + error?: Error | string + url: string + pathToFile: string + fileSize?: number | bigint + key: ArtifactKind + errorStack?: string + allErrors?: Error[] + specAccess?: { + offset: number + size: number + } + uploadDuration?: number +} + +export type ArtifactUploadStrategy = (filePath: string, uploadUrl: string, fileSize: number | bigint) => T + +export class Artifact, UploadResponse extends Promise = Promise<{}>> { + constructor ( + public reportKey: ArtifactKind, + public readonly filePath: string, + public readonly uploadUrl: string, + public readonly fileSize: number | bigint, + private uploadStrategy: T, + ) { + } + + public async upload (): Promise { + const startTime = performance.now() + + this.debug('upload starting') + + try { + const response = await this.uploadStrategy(this.filePath, this.uploadUrl, this.fileSize) + + this.debug('upload succeeded: %O', response) + + return this.composeSuccessResult(response ?? {}, performance.now() - startTime) + } catch (e) { + this.debug('upload failed: %O', e) + + return this.composeFailureResult(e, performance.now() - startTime) + } + } + + private debug (formatter: string = '', ...args: (string | object | number)[]) { + if (!debug.enabled) return + + debug(`%s: %s -> %s (%dB) ${formatter}`, this.reportKey, this.filePath, this.uploadUrl, this.fileSize, ...args) + } + + private commonResultFields (): Pick { + return { + key: this.reportKey, + url: this.uploadUrl, + pathToFile: this.filePath, + fileSize: this.fileSize, + } + } + + protected composeSuccessResult (response: T, uploadDuration: number): ArtifactUploadResult { + return { + ...response, + ...this.commonResultFields(), + success: true, + uploadDuration, + } + } + + protected composeFailureResult (err: T, uploadDuration: number): ArtifactUploadResult { + const errorReport = isAggregateError(err) ? { + error: err.errors[err.errors.length - 1].message, + errorStack: err.errors[err.errors.length - 1].stack, + allErrors: err.errors, + } : { + error: err.message, + errorStack: err.stack, + } + + return { + ...errorReport, + ...this.commonResultFields(), + success: false, + uploadDuration, + } + } +} diff --git a/packages/server/lib/cloud/artifacts/file_upload_strategy.ts b/packages/server/lib/cloud/artifacts/file_upload_strategy.ts new file mode 100644 index 000000000000..fa730e16b098 --- /dev/null +++ b/packages/server/lib/cloud/artifacts/file_upload_strategy.ts @@ -0,0 +1,6 @@ +import { sendFile } from '../upload/send_file' +import type { ArtifactUploadStrategy } from './artifact' + +export const fileUploadStrategy: ArtifactUploadStrategy> = (filePath, uploadUrl) => { + return sendFile(filePath, uploadUrl) +} diff --git a/packages/server/lib/cloud/artifacts/protocol_artifact.ts b/packages/server/lib/cloud/artifacts/protocol_artifact.ts new file mode 100644 index 000000000000..78241acf4637 --- /dev/null +++ b/packages/server/lib/cloud/artifacts/protocol_artifact.ts @@ -0,0 +1,61 @@ +import fs from 'fs/promises' +import type { ProtocolManager } from '../protocol' +import { IArtifact, ArtifactUploadStrategy, ArtifactUploadResult, Artifact, ArtifactKinds } from './artifact' + +interface ProtocolUploadStrategyResult { + success: boolean + fileSize: number | bigint + specAccess: { + offset: number + size: number + } +} + +const createProtocolUploadStrategy = (protocolManager: ProtocolManager) => { + const strategy: ArtifactUploadStrategy> = + async (filePath, uploadUrl, fileSize) => { + const fatalError = protocolManager.getFatalError() + + if (fatalError) { + throw fatalError.error + } + + const res = await protocolManager.uploadCaptureArtifact({ uploadUrl, fileSize, filePath }) + + return res ?? {} + } + + return strategy +} + +export const createProtocolArtifact = async (filePath: string, uploadUrl: string, protocolManager: ProtocolManager): Promise => { + const { size } = await fs.stat(filePath) + + return new Artifact('protocol', filePath, uploadUrl, size, createProtocolUploadStrategy(protocolManager)) +} + +export const composeProtocolErrorReportFromOptions = async ({ + protocolManager, + protocolCaptureMeta, + captureUploadUrl, +}: { + protocolManager?: ProtocolManager + protocolCaptureMeta: { url?: string, disabledMessage?: string } + captureUploadUrl?: string +}): Promise => { + const url = captureUploadUrl || protocolCaptureMeta.url + const pathToFile = protocolManager?.getArchivePath() + const fileSize = pathToFile ? (await fs.stat(pathToFile))?.size : 0 + + const fatalError = protocolManager?.getFatalError() + + return { + key: ArtifactKinds.PROTOCOL, + url: url ?? 'UNKNOWN', + pathToFile: pathToFile ?? 'UNKNOWN', + fileSize, + success: false, + error: fatalError?.error.message || 'UNKNOWN', + errorStack: fatalError?.error.stack || 'UNKNOWN', + } +} diff --git a/packages/server/lib/cloud/artifacts/screenshot_artifact.ts b/packages/server/lib/cloud/artifacts/screenshot_artifact.ts new file mode 100644 index 000000000000..e32c5b19978b --- /dev/null +++ b/packages/server/lib/cloud/artifacts/screenshot_artifact.ts @@ -0,0 +1,44 @@ +import fs from 'fs/promises' +import Debug from 'debug' +import { Artifact, IArtifact, ArtifactKinds } from './artifact' +import { fileUploadStrategy } from './file_upload_strategy' + +const debug = Debug('cypress:server:cloud:artifacts:screenshot') + +const createScreenshotArtifact = async (filePath: string, uploadUrl: string): Promise => { + try { + const { size } = await fs.stat(filePath) + + return new Artifact(ArtifactKinds.SCREENSHOTS, filePath, uploadUrl, size, fileUploadStrategy) + } catch (e) { + debug('Error creating screenshot artifact: %O', e) + + return + } +} + +export const createScreenshotArtifactBatch = ( + screenshotUploadUrls: {screenshotId: string, uploadUrl: string}[], + screenshotFiles: {screenshotId: string, path: string}[], +): Promise => { + const correlatedPaths = screenshotUploadUrls.map(({ screenshotId, uploadUrl }) => { + const correlatedFilePath = screenshotFiles.find((pathPair) => { + return pathPair.screenshotId === screenshotId + })?.path + + return correlatedFilePath ? { + filePath: correlatedFilePath, + uploadUrl, + } : undefined + }).filter((pair): pair is { filePath: string, uploadUrl: string } => { + return !!pair + }) + + return Promise.all(correlatedPaths.map(({ filePath, uploadUrl }) => { + return createScreenshotArtifact(filePath, uploadUrl) + })).then((artifacts) => { + return artifacts.filter((artifact): artifact is IArtifact => { + return !!artifact + }) + }) +} diff --git a/packages/server/lib/cloud/artifacts/upload_artifacts.ts b/packages/server/lib/cloud/artifacts/upload_artifacts.ts new file mode 100644 index 000000000000..b06593913552 --- /dev/null +++ b/packages/server/lib/cloud/artifacts/upload_artifacts.ts @@ -0,0 +1,204 @@ +import Debug from 'debug' +import type ProtocolManager from '../protocol' +import api from '../api' +import { logUploadManifest, logUploadResults, beginUploadActivityOutput } from '../../util/print-run' +import type { UpdateInstanceArtifactsPayload, ArtifactMetadata, ProtocolMetadata } from '../api' +import * as errors from '../../errors' +import exception from '../exception' +import { IArtifact, ArtifactUploadResult, ArtifactKinds } from './artifact' +import { createScreenshotArtifactBatch } from './screenshot_artifact' +import { createVideoArtifact } from './video_artifact' +import { createProtocolArtifact, composeProtocolErrorReportFromOptions } from './protocol_artifact' + +const debug = Debug('cypress:server:cloud:artifacts') + +const toUploadReportPayload = (acc: { + screenshots: ArtifactMetadata[] + video?: ArtifactMetadata + protocol?: ProtocolMetadata +}, { key, ...report }: ArtifactUploadResult): UpdateInstanceArtifactsPayload => { + if (key === ArtifactKinds.PROTOCOL) { + let { error, errorStack, allErrors } = report + + if (allErrors) { + error = `Failed to upload Test Replay after ${allErrors.length} attempts. Errors: ${allErrors.map((error) => error.message).join(', ')}` + errorStack = allErrors.map((error) => error.stack).join(', ') + } else if (error) { + error = `Failed to upload Test Replay: ${error}` + } + + debug('protocol report %O', report) + + return { + ...acc, + protocol: { + ...report, + error, + errorStack, + }, + } + } + + return { + ...acc, + [key]: (key === 'screenshots') ? [...acc.screenshots, report] : report, + } +} + +type UploadArtifactOptions = { + protocolManager?: ProtocolManager + videoUploadUrl?: string + video?: string // filepath to the video artifact + screenshots?: { + screenshotId: string + path: string + }[] + screenshotUploadUrls?: { + screenshotId: string + uploadUrl: string + }[] + captureUploadUrl?: string + protocolCaptureMeta: { + url?: string + disabledMessage?: string + } + quiet?: boolean + runId: string + instanceId: string + spec: any + platform: any + projectId: any +} + +const extractArtifactsFromOptions = async ({ + video, videoUploadUrl, screenshots, screenshotUploadUrls, captureUploadUrl, protocolCaptureMeta, protocolManager, +}: Pick): Promise => { + const artifacts: IArtifact[] = [] + + if (videoUploadUrl && video) { + try { + artifacts.push(await createVideoArtifact(video, videoUploadUrl)) + } catch (e) { + debug('Error creating video artifact: %O', e) + } + } + + debug('screenshot metadata: %O', { screenshotUploadUrls, screenshots }) + debug('found screenshot filenames: %o', screenshots) + if (screenshots?.length && screenshotUploadUrls?.length) { + const screenshotArtifacts = await createScreenshotArtifactBatch(screenshotUploadUrls, screenshots) + + screenshotArtifacts.forEach((screenshot) => { + artifacts.push(screenshot) + }) + } + + try { + const protocolFilePath = protocolManager?.getArchivePath() + + const protocolUploadUrl = captureUploadUrl || protocolCaptureMeta.url + + debug('should add protocol artifact? %o, %o, %O', protocolFilePath, protocolUploadUrl, protocolManager) + if (protocolManager && protocolFilePath && protocolUploadUrl) { + artifacts.push(await createProtocolArtifact(protocolFilePath, protocolUploadUrl, protocolManager)) + } + } catch (e) { + debug('Error creating protocol artifact: %O', e) + } + + return artifacts +} + +export const uploadArtifacts = async (options: UploadArtifactOptions) => { + const { protocolManager, protocolCaptureMeta, quiet, runId, instanceId, spec, platform, projectId } = options + + const priority = { + [ArtifactKinds.VIDEO]: 0, + [ArtifactKinds.SCREENSHOTS]: 1, + [ArtifactKinds.PROTOCOL]: 2, + } + + const artifacts = (await extractArtifactsFromOptions(options)).sort((a, b) => { + return priority[a.reportKey] - priority[b.reportKey] + }) + + let uploadReport: UpdateInstanceArtifactsPayload + + if (!quiet) { + logUploadManifest(artifacts, protocolCaptureMeta, protocolManager?.getFatalError()) + } + + debug('preparing to upload artifacts: %O', artifacts) + + let stopUploadActivityOutput: () => void | undefined + + if (!quiet && artifacts.length) { + stopUploadActivityOutput = beginUploadActivityOutput() + } + + try { + const uploadResults = await Promise.all(artifacts.map((artifact) => artifact.upload())).finally(() => { + if (stopUploadActivityOutput) { + stopUploadActivityOutput() + } + }) + + if (!quiet && uploadResults.length) { + logUploadResults(uploadResults, protocolManager?.getFatalError()) + } + + const protocolFatalError = protocolManager?.getFatalError() + + /** + * Protocol instances with fatal errors prior to uploading will not have an uploadResult, + * but we still want to report them to updateInstanceArtifacts + */ + if (!uploadResults.find((result: ArtifactUploadResult) => { + return result.key === ArtifactKinds.PROTOCOL + }) && protocolFatalError) { + uploadResults.push(await composeProtocolErrorReportFromOptions(options)) + } + + uploadReport = uploadResults.reduce(toUploadReportPayload, { video: undefined, screenshots: [], protocol: undefined }) + } catch (err) { + errors.warning('CLOUD_CANNOT_UPLOAD_ARTIFACTS', err) + + return exception.create(err) + } + + debug('checking for protocol errors', protocolManager?.hasErrors()) + if (protocolManager) { + try { + await protocolManager.reportNonFatalErrors({ + specName: spec.name, + osName: platform.osName, + projectSlug: projectId, + }) + } catch (err) { + debug('Failed to send protocol errors %O', err) + } + } + + try { + debug('upload report: %O', uploadReport) + const res = await api.updateInstanceArtifacts({ + runId, instanceId, + }, uploadReport) + + return res + } catch (err) { + debug('failed updating artifact status %o', { + stack: err.stack, + }) + + errors.warning('CLOUD_CANNOT_UPLOAD_ARTIFACTS_PROTOCOL', err) + + if (err.statusCode !== 503) { + return exception.create(err) + } + } +} diff --git a/packages/server/lib/cloud/artifacts/video_artifact.ts b/packages/server/lib/cloud/artifacts/video_artifact.ts new file mode 100644 index 000000000000..f7071aaccd53 --- /dev/null +++ b/packages/server/lib/cloud/artifacts/video_artifact.ts @@ -0,0 +1,9 @@ +import fs from 'fs/promises' +import { Artifact, IArtifact, ArtifactKinds } from './artifact' +import { fileUploadStrategy } from './file_upload_strategy' + +export const createVideoArtifact = async (filePath: string, uploadUrl: string): Promise => { + const { size } = await fs.stat(filePath) + + return new Artifact(ArtifactKinds.VIDEO, filePath, uploadUrl, size, fileUploadStrategy) +} diff --git a/packages/server/lib/cloud/exception.ts b/packages/server/lib/cloud/exception.ts index 724f2d6d5e31..dfdcdc4c044a 100644 --- a/packages/server/lib/cloud/exception.ts +++ b/packages/server/lib/cloud/exception.ts @@ -1,7 +1,7 @@ import _ from 'lodash' const Promise = require('bluebird') const pkg = require('@packages/root') -const api = require('./api') +const api = require('./api').default const user = require('./user') const system = require('../util/system') diff --git a/packages/server/lib/cloud/protocol.ts b/packages/server/lib/cloud/protocol.ts index fa2ee2e9e106..442f5005b706 100644 --- a/packages/server/lib/cloud/protocol.ts +++ b/packages/server/lib/cloud/protocol.ts @@ -259,6 +259,10 @@ export class ProtocolManager implements ProtocolManagerShape { return this._errors.filter((e) => !e.fatal) } + getArchivePath (): string | undefined { + return this._archivePath + } + async getArchiveInfo (): Promise<{ filePath: string, fileSize: number } | void> { const archivePath = this._archivePath @@ -275,9 +279,9 @@ export class ProtocolManager implements ProtocolManagerShape { async uploadCaptureArtifact ({ uploadUrl, fileSize, filePath }: CaptureArtifact): Promise<{ success: boolean - fileSize: number - specAccess?: ReturnType - } | void> { + fileSize: number | bigint + specAccess: ReturnType + } | undefined> { if (!this._protocol || !filePath || !this._db) { debug('not uploading due to one of the following being falsy: %O', { _protocol: !!this._protocol, @@ -296,7 +300,7 @@ export class ProtocolManager implements ProtocolManagerShape { return { fileSize, success: true, - specAccess: this._protocol?.getDbMetadata(), + specAccess: this._protocol.getDbMetadata(), } } catch (e) { if (CAPTURE_ERRORS) { @@ -304,10 +308,13 @@ export class ProtocolManager implements ProtocolManagerShape { error: e, captureMethod: 'uploadCaptureArtifact', fatal: true, + isUploadError: true, }) throw e } + + return } finally { if (DELETE_DB) { await fs.unlink(filePath).catch((e) => { diff --git a/packages/server/lib/cloud/upload.ts b/packages/server/lib/cloud/upload.ts deleted file mode 100644 index 6c421802ddac..000000000000 --- a/packages/server/lib/cloud/upload.ts +++ /dev/null @@ -1,16 +0,0 @@ -const rp = require('@cypress/request-promise') -const { fs } = require('../util/fs') - -export = { - send (pathToFile: string, url: string) { - return fs - .readFileAsync(pathToFile) - .then((buf) => { - return rp({ - url, - method: 'PUT', - body: buf, - }) - }) - }, -} diff --git a/packages/server/lib/cloud/upload/send_file.ts b/packages/server/lib/cloud/upload/send_file.ts new file mode 100644 index 000000000000..e1f7eeeec4f1 --- /dev/null +++ b/packages/server/lib/cloud/upload/send_file.ts @@ -0,0 +1,14 @@ +const rp = require('@cypress/request-promise') +const { fs } = require('../../util/fs') + +export const sendFile = (filePath: string, uploadUrl: string) => { + return fs + .readFileAsync(filePath) + .then((buf) => { + return rp({ + url: uploadUrl, + method: 'PUT', + body: buf, + }) + }) +} diff --git a/packages/server/lib/cloud/user.ts b/packages/server/lib/cloud/user.ts index 90e08c6de147..89c53fcc57f4 100644 --- a/packages/server/lib/cloud/user.ts +++ b/packages/server/lib/cloud/user.ts @@ -1,4 +1,4 @@ -const api = require('./api') +const api = require('./api').default const cache = require('../cache') import type { CachedUser } from '@packages/types' @@ -25,6 +25,8 @@ export = { if (authToken) { return api.postLogout(authToken) } + + return undefined }) }) }, diff --git a/packages/server/lib/modes/record.js b/packages/server/lib/modes/record.js index 5eb9f6864ba9..234123efdb5c 100644 --- a/packages/server/lib/modes/record.js +++ b/packages/server/lib/modes/record.js @@ -1,7 +1,6 @@ const _ = require('lodash') const path = require('path') const la = require('lazy-ass') -const chalk = require('chalk') const check = require('check-more-types') const debug = require('debug')('cypress:server:record') const debugCiInfo = require('debug')('cypress:server:record:ci-info') @@ -12,21 +11,19 @@ const { telemetry } = require('@packages/telemetry') const { hideKeys } = require('@packages/config') -const api = require('../cloud/api') +const api = require('../cloud/api').default const exception = require('../cloud/exception') -const upload = require('../cloud/upload') const errors = require('../errors') const capture = require('../capture') const Config = require('../config') const env = require('../util/env') -const terminal = require('../util/terminal') const ciProvider = require('../util/ci_provider') -const { printPendingArtifactUpload, printCompletedArtifactUpload, beginUploadActivityOutput } = require('../util/print-run') + const testsUtils = require('../util/tests_utils') const specWriter = require('../util/spec_writer') -const { fs } = require('../util/fs') -const { performance } = require('perf_hooks') + +const { uploadArtifacts } = require('../cloud/artifacts/upload_artifacts') // dont yell about any errors either const runningInternalTests = () => { @@ -138,365 +135,6 @@ returns: ] */ -const uploadArtifactBatch = async (artifacts, protocolManager, quiet) => { - const priority = { - 'video': 0, - 'screenshots': 1, - 'protocol': 2, - } - const labels = { - 'video': 'Video', - 'screenshots': 'Screenshot', - 'protocol': 'Test Replay', - } - - artifacts.sort((a, b) => { - return priority[a.reportKey] - priority[b.reportKey] - }) - - const preparedArtifacts = await Promise.all(artifacts.map(async (artifact) => { - if (artifact.skip) { - return artifact - } - - if (artifact.reportKey === 'protocol') { - try { - if (protocolManager.hasFatalError()) { - const error = protocolManager.getFatalError().error - - debug('protocol fatal error encountered', { - message: error.message, - captureMethod: error.captureMethod, - stack: error.stack, - }) - - return { - ...artifact, - skip: true, - error: error.message || 'Unknown Error', - errorStack: error.stack || 'Unknown Stack', - } - } - - const archiveInfo = await protocolManager.getArchiveInfo() - - if (archiveInfo === undefined) { - return { - ...artifact, - skip: true, - error: 'No test data recorded', - } - } - - return { - ...artifact, - ...archiveInfo, - } - } catch (err) { - debug('failed to prepare protocol artifact', { - error: err.message, - stack: err.stack, - }) - - return { - ...artifact, - skip: true, - error: err.message, - errorStack: err.stack, - } - } - } - - if (artifact.filePath) { - try { - const { size } = await fs.statAsync(artifact.filePath) - - return { - ...artifact, - fileSize: size, - } - } catch (err) { - debug('failed to get stats for upload artifact %o', { - file: artifact.filePath, - stack: err.stack, - }) - - return { - ...artifact, - skip: true, - error: err.message, - errorStack: err.stack, - } - } - } - - return artifact - })) - - if (!quiet) { - // eslint-disable-next-line no-console - console.log('') - - terminal.header('Uploading Cloud Artifacts', { - color: ['blue'], - }) - - // eslint-disable-next-line no-console - console.log('') - } - - preparedArtifacts.forEach((artifact) => { - debug('preparing to upload artifact %O', { - ...artifact, - payload: typeof artifact.payload, - }) - - if (!quiet) { - printPendingArtifactUpload(artifact, labels) - } - }) - - let stopUploadActivityOutput - - if (!quiet && preparedArtifacts.filter(({ skip }) => !skip).length) { - stopUploadActivityOutput = beginUploadActivityOutput() - } - - const uploadResults = await Promise.all( - preparedArtifacts.map(async (artifact) => { - if (artifact.skip) { - debug('nothing to upload for artifact %O', artifact) - - return { - key: artifact.reportKey, - skipped: true, - url: artifact.uploadUrl, - ...(artifact.error && { - error: artifact.error, - errorStack: artifact.errorStack, - success: false, - }), - } - } - - const startTime = performance.now() - - debug('uploading artifact %O', { - ...artifact, - payload: typeof artifact.payload, - }) - - try { - if (artifact.reportKey === 'protocol') { - const res = await protocolManager.uploadCaptureArtifact(artifact) - - return { - ...res, - pathToFile: 'Test Replay', - url: artifact.uploadUrl, - fileSize: artifact.fileSize, - key: artifact.reportKey, - uploadDuration: performance.now() - startTime, - } - } - - const res = await upload.send(artifact.filePath, artifact.uploadUrl) - - return { - ...res, - success: true, - url: artifact.uploadUrl, - pathToFile: artifact.filePath, - fileSize: artifact.fileSize, - key: artifact.reportKey, - uploadDuration: performance.now() - startTime, - } - } catch (err) { - debug('failed to upload artifact %o', { - file: artifact.filePath, - url: artifact.uploadUrl, - stack: err.stack, - }) - - if (err.errors) { - const lastError = _.last(err.errors) - - return { - key: artifact.reportKey, - success: false, - error: lastError.message, - allErrors: err.errors, - url: artifact.uploadUrl, - pathToFile: artifact.filePath, - uploadDuration: performance.now() - startTime, - } - } - - return { - key: artifact.reportKey, - success: false, - error: err.message, - errorStack: err.stack, - url: artifact.uploadUrl, - pathToFile: artifact.filePath, - uploadDuration: performance.now() - startTime, - } - } - }), - ).finally(() => { - if (stopUploadActivityOutput) { - stopUploadActivityOutput() - } - }) - - const attemptedUploadResults = uploadResults.filter(({ skipped }) => { - return !skipped - }) - - if (!quiet && attemptedUploadResults.length) { - // eslint-disable-next-line no-console - console.log('') - - terminal.header('Uploaded Cloud Artifacts', { - color: ['blue'], - }) - - // eslint-disable-next-line no-console - console.log('') - - attemptedUploadResults.forEach(({ key, skipped, ...report }, i, { length }) => { - printCompletedArtifactUpload({ key, ...report }, labels, chalk.grey(`${i + 1}/${length}`)) - }) - } - - return uploadResults.reduce((acc, { key, skipped, ...report }) => { - if (key === 'protocol') { - let { error, errorStack, allErrors } = report - - if (allErrors) { - error = `Failed to upload Test Replay after ${allErrors.length} attempts. Errors: ${allErrors.map((error) => error.message).join(', ')}` - errorStack = allErrors.map((error) => error.stack).join(', ') - } else if (error) { - error = `Failed to upload Test Replay: ${error}` - } - - return skipped && !report.error ? acc : { - ...acc, - [key]: { - ...report, - error, - errorStack, - }, - } - } - - return skipped ? acc : { - ...acc, - [key]: (key === 'screenshots') ? [...acc.screenshots, report] : report, - } - }, { - video: undefined, - screenshots: [], - protocol: undefined, - }) -} - -const uploadArtifacts = async (options = {}) => { - const { protocolManager, video, screenshots, videoUploadUrl, captureUploadUrl, protocolCaptureMeta, screenshotUploadUrls, quiet, runId, instanceId, spec, platform, projectId } = options - - const artifacts = [] - - if (videoUploadUrl) { - artifacts.push({ - reportKey: 'video', - uploadUrl: videoUploadUrl, - filePath: video, - }) - } else { - artifacts.push({ - reportKey: 'video', - skip: true, - }) - } - - if (screenshotUploadUrls.length) { - screenshotUploadUrls.map(({ screenshotId, uploadUrl }) => { - const screenshot = _.find(screenshots, { screenshotId }) - - debug('screenshot: %o', screenshot) - - return { - reportKey: 'screenshots', - uploadUrl, - filePath: screenshot.path, - } - }).forEach((screenshotArtifact) => { - artifacts.push(screenshotArtifact) - }) - } else { - artifacts.push({ - reportKey: 'screenshots', - skip: true, - }) - } - - debug('capture manifest: %O', { captureUploadUrl, protocolCaptureMeta, protocolManager }) - if (protocolManager && (captureUploadUrl || (protocolCaptureMeta && protocolCaptureMeta.url))) { - artifacts.push({ - reportKey: 'protocol', - uploadUrl: captureUploadUrl || protocolCaptureMeta.url, - }) - } else if (protocolCaptureMeta && protocolCaptureMeta.disabledMessage) { - artifacts.push({ - reportKey: 'protocol', - message: protocolCaptureMeta.disabledMessage, - skip: true, - }) - } - - let uploadReport - - try { - uploadReport = await uploadArtifactBatch(artifacts, protocolManager, quiet) - } catch (err) { - errors.warning('CLOUD_CANNOT_UPLOAD_ARTIFACTS', err) - - return exception.create(err) - } - - debug('checking for protocol errors', protocolManager?.hasErrors()) - if (protocolManager) { - try { - await protocolManager.reportNonFatalErrors({ - specName: spec.name, - osName: platform.osName, - projectSlug: projectId, - }) - } catch (err) { - debug('Failed to send protocol errors %O', err) - } - } - - try { - debug('upload report: %O', uploadReport) - const res = await api.updateInstanceArtifacts({ - runId, instanceId, - }, uploadReport) - - return res - } catch (err) { - debug('failed updating artifact status %o', { - stack: err.stack, - }) - - errors.warning('CLOUD_CANNOT_UPLOAD_ARTIFACTS_PROTOCOL', err) - - if (err.statusCode !== 503) { - return exception.create(err) - } - } -} - const updateInstanceStdout = async (options = {}) => { const { runId, instanceId, captured } = options diff --git a/packages/server/lib/util/print-run.ts b/packages/server/lib/util/print-run.ts index 200f726f6841..d6a35a69d65b 100644 --- a/packages/server/lib/util/print-run.ts +++ b/packages/server/lib/util/print-run.ts @@ -12,11 +12,12 @@ import env from './env' import terminal from './terminal' import { getIsCi } from './ci_provider' import * as experiments from '../experiments' -import type { SpecFile } from '@packages/types' +import type { SpecFile, ProtocolError } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import type { Table } from 'cli-table3' import type { CypressRunResult } from '../modes/results' +import type { IArtifact, ArtifactUploadResult } from '../cloud/artifacts/artifact' type Screenshot = { width: number @@ -572,30 +573,9 @@ const formatFileSize = (bytes: number) => { return prettyBytes(bytes) } -type ArtifactLike = { - reportKey: 'protocol' | 'screenshots' | 'video' - filePath?: string - fileSize?: number | BigInt - message?: string - skip?: boolean - error: string -} - -export const printPendingArtifactUpload = (artifact: T, labels: Record<'protocol' | 'screenshots' | 'video', string>): void => { +export const printPendingArtifactUpload = (artifact: IArtifact, labels: Record<'protocol' | 'screenshots' | 'video', string>): void => { process.stdout.write(` - ${labels[artifact.reportKey]} `) - if (artifact.skip) { - if (artifact.reportKey === 'protocol' && artifact.error) { - process.stdout.write(`- Failed Capturing - ${artifact.error}`) - } else { - process.stdout.write('- Nothing to upload ') - } - } - - if (artifact.reportKey === 'protocol' && artifact.message) { - process.stdout.write(`- ${artifact.message}`) - } - if (artifact.fileSize) { process.stdout.write(`- ${formatFileSize(Number(artifact.fileSize))}`) } @@ -607,25 +587,69 @@ export const printPendingArtifactUpload = (artifact: T, process.stdout.write('\n') } -type ArtifactUploadResultLike = { - pathToFile?: string - key: string - fileSize?: number | BigInt - success: boolean - error?: string - skipped?: boolean - uploadDuration?: number +export const printSkippedArtifact = (label: string, message: string = 'Nothing to upload', error?: string) => { + process.stdout.write(` - ${label} - ${message} `) + if (error) { + process.stdout.write(`- ${error}`) + } + + process.stdout.write('\n') } -export const printCompletedArtifactUpload = (artifactUploadResult: T, labels: Record<'protocol' | 'screenshots' | 'video', string>, num: string): void => { - const { pathToFile, key, fileSize, success, error, skipped, uploadDuration } = artifactUploadResult +export const logUploadManifest = (artifacts: IArtifact[], protocolCaptureMeta: { + url?: string + disabledMessage?: string +}, protocolFatalError?: ProtocolError) => { + const labels = { + 'video': 'Video', + 'screenshots': 'Screenshot', + 'protocol': 'Test Replay', + } + + // eslint-disable-next-line no-console + console.log('') + terminal.header('Uploading Cloud Artifacts', { + color: ['blue'], + }) + + // eslint-disable-next-line no-console + console.log('') + const video = artifacts.find(({ reportKey }) => reportKey === 'video') + const screenshots = artifacts.filter(({ reportKey }) => reportKey === 'screenshots') + const protocol = artifacts.find(({ reportKey }) => reportKey === 'protocol') + + if (video) { + printPendingArtifactUpload(video, labels) + } else { + printSkippedArtifact('Video') + } + + if (screenshots.length) { + screenshots.forEach(((screenshot) => { + printPendingArtifactUpload(screenshot, labels) + })) + } else { + printSkippedArtifact('Screenshot') + } + + // if protocolFatalError exists here, there is not a protocol artifact to attempt to upload + if (protocolFatalError) { + printSkippedArtifact('Test Replay', 'Failed Capturing', protocolFatalError.error.message) + } else if (protocol) { + if (!protocolFatalError) { + printPendingArtifactUpload(protocol, labels) + } + } else if (protocolCaptureMeta.disabledMessage) { + printSkippedArtifact('Test Replay', 'Nothing to upload', protocolCaptureMeta.disabledMessage) + } +} + +export const printCompletedArtifactUpload = ({ pathToFile, key, fileSize, success, error, uploadDuration }: ArtifactUploadResult, labels: Record<'protocol' | 'screenshots' | 'video', string>, num: string): void => { process.stdout.write(` - ${labels[key]} `) if (success) { process.stdout.write(`- Done Uploading ${formatFileSize(Number(fileSize))}`) - } else if (skipped) { - process.stdout.write(`- Nothing to Upload`) } else { process.stdout.write(`- Failed Uploading`) } @@ -649,6 +673,40 @@ export const printCompletedArtifactUpload = process.stdout.write('\n') } +export const logUploadResults = (results: ArtifactUploadResult[], protocolFatalError: ProtocolError | undefined) => { + const labels = { + 'video': 'Video', + 'screenshots': 'Screenshot', + 'protocol': 'Test Replay', + } + + // if protocol did not attempt an upload due to a fatal error, there will still be an upload result - this is + // so we can report the failure properly to instance/artifacts. But, we do not want to display it here. + const trimmedResults = protocolFatalError && protocolFatalError.captureMethod !== 'uploadCaptureArtifact' ? + results.filter(((result) => { + return result.key !== 'protocol' + })) : + results + + if (!trimmedResults.length) { + return + } + + // eslint-disable-next-line no-console + console.log('') + + terminal.header('Uploaded Cloud Artifacts', { + color: ['blue'], + }) + + // eslint-disable-next-line no-console + console.log('') + + trimmedResults.forEach(({ key, ...report }, i, { length }) => { + printCompletedArtifactUpload({ key, ...report }, labels, chalk.grey(`${i + 1}/${length}`)) + }) +} + const UPLOAD_ACTIVITY_INTERVAL = typeof env.get('CYPRESS_UPLOAD_ACTIVITY_INTERVAL') === 'undefined' ? 15000 : env.get('CYPRESS_UPLOAD_ACTIVITY_INTERVAL') export const beginUploadActivityOutput = () => { diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index f7957c35a0cf..756d31947c66 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -24,7 +24,7 @@ const ciProvider = require(`../../lib/util/ci_provider`) const settings = require(`../../lib/util/settings`) const Windows = require(`../../lib/gui/windows`) const interactiveMode = require(`../../lib/modes/interactive`) -const api = require(`../../lib/cloud/api`) +const api = require(`../../lib/cloud/api`).default const cwd = require(`../../lib/cwd`) const user = require(`../../lib/cloud/user`) const cache = require(`../../lib/cache`) diff --git a/packages/server/test/unit/cloud/api/api_spec.js b/packages/server/test/unit/cloud/api/api_spec.js index 9555d2724541..79c146ba4e52 100644 --- a/packages/server/test/unit/cloud/api/api_spec.js +++ b/packages/server/test/unit/cloud/api/api_spec.js @@ -13,7 +13,7 @@ const { agent, } = require('@packages/network') const pkg = require('@packages/root') -const api = require('../../../../lib/cloud/api') +const api = require('../../../../lib/cloud/api').default const cache = require('../../../../lib/cache') const errors = require('../../../../lib/errors') const machineId = require('../../../../lib/cloud/machine_id') @@ -237,7 +237,7 @@ describe('lib/cloud/api', () => { if (!prodApi) { prodApi = stealthyRequire(require.cache, () => { - return require('../../../../lib/cloud/api') + return require('../../../../lib/cloud/api').default }, () => { require('../../../../lib/cloud/encryption') }, module) diff --git a/packages/server/test/unit/cloud/exceptions_spec.js b/packages/server/test/unit/cloud/exceptions_spec.js index 26be8e578466..20bbe786f796 100644 --- a/packages/server/test/unit/cloud/exceptions_spec.js +++ b/packages/server/test/unit/cloud/exceptions_spec.js @@ -2,7 +2,7 @@ require('../../spec_helper') delete global.fs -const api = require('../../../lib/cloud/api') +const api = require('../../../lib/cloud/api').default const user = require('../../../lib/cloud/user') const exception = require('../../../lib/cloud/exception') const system = require('../../../lib/util/system') diff --git a/packages/server/test/unit/cloud/user_spec.js b/packages/server/test/unit/cloud/user_spec.js index d7d12c5b7e85..71a9633584c7 100644 --- a/packages/server/test/unit/cloud/user_spec.js +++ b/packages/server/test/unit/cloud/user_spec.js @@ -1,6 +1,6 @@ require('../../spec_helper') -const api = require('../../../lib/cloud/api') +const api = require('../../../lib/cloud/api').default const cache = require('../../../lib/cache') const user = require('../../../lib/cloud/user') diff --git a/packages/server/test/unit/modes/record_spec.js b/packages/server/test/unit/modes/record_spec.js index 1e65c47f883e..e0dd1af7faef 100644 --- a/packages/server/test/unit/modes/record_spec.js +++ b/packages/server/test/unit/modes/record_spec.js @@ -6,7 +6,7 @@ const commitInfo = require('@cypress/commit-info') const mockedEnv = require('mocked-env') const errors = require(`../../../lib/errors`) -const api = require(`../../../lib/cloud/api`) +const api = require(`../../../lib/cloud/api`).default const exception = require(`../../../lib/cloud/exception`) const recordMode = require(`../../../lib/modes/record`) const ciProvider = require(`../../../lib/util/ci_provider`) diff --git a/packages/types/src/protocol.ts b/packages/types/src/protocol.ts index 80b8d0254386..6d41abc3c6cd 100644 --- a/packages/types/src/protocol.ts +++ b/packages/types/src/protocol.ts @@ -49,6 +49,7 @@ export interface ProtocolError { captureMethod: ProtocolCaptureMethod fatal?: boolean runnableId?: string + isUploadError?: boolean } type ProtocolErrorReportEntry = Omit & { @@ -74,7 +75,7 @@ export type ProtocolErrorReport = { export type CaptureArtifact = { uploadUrl: string - fileSize: number + fileSize: number | bigint filePath: string } @@ -90,7 +91,7 @@ export interface ProtocolManagerShape extends AppCaptureProtocolCommon { setupProtocol(script: string, options: ProtocolManagerOptions): Promise beforeSpec (spec: { instanceId: string }): void reportNonFatalErrors (clientMetadata: any): Promise - uploadCaptureArtifact(artifact: CaptureArtifact, timeout?: number): Promise<{ fileSize: number, success: boolean, error?: string } | void> + uploadCaptureArtifact(artifact: CaptureArtifact, timeout?: number): Promise<{ fileSize: number | bigint, success: boolean, error?: string } | void> } type Response = {