diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 3efadcb..3476c0d 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -3,8 +3,13 @@ import { defineCommand } from 'citty'; import * as path from 'node:path'; import { flags as allFlags } from '../constants.js'; -import { ApiGateway } from '../gateways/api-gateway.js'; -import { uploadBinary, verifyAppZip, writeJSONFile } from '../methods.js'; +import { ApiError, ApiGateway } from '../gateways/api-gateway.js'; +import { + uploadBinary, + uploadFlowZip, + verifyAppZip, + writeJSONFile, +} from '../methods.js'; import { DeviceValidationService } from '../services/device-validation.service.js'; import { plan } from '../services/execution-plan.service.js'; import { @@ -701,7 +706,7 @@ export const cloudCommand = defineCommand({ if (ghPrUrl) ghMetadataOverrides.push(`gh_pr_url=${ghPrUrl}`); const mergedMetadata = [...ghMetadataOverrides, ...metadata]; - const testFormData = await testSubmissionService.buildTestFormData({ + const { buffer, fields } = await testSubmissionService.buildTestPayload({ androidApiLevel, androidDevice, androidNoSnapshot, @@ -733,18 +738,48 @@ export const cloudCommand = defineCommand({ disableAnimations, }); - if (debug) { - out(`[DEBUG] Submitting flow upload request to ${apiUrl}/uploads/flow`); + // New path: upload the zip directly to storage, then submit a JSON test + // referencing it. Older API deployments lack these endpoints — a real API + // 404s (route undefined), some proxies 405 (path/method not allowed); in + // either case fall back to the legacy multipart POST /uploads/flow, which + // is byte-identical bar the storage reference. + let response: Awaited>; + try { + const storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug }); + + if (debug) { + out( + `[DEBUG] Flow zip uploaded (id=${storageRef.id}, supabase=${storageRef.supabaseSuccess}, backblaze=${storageRef.backblazeSuccess})`, + ); + out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`); + } + + response = await ApiGateway.submitFlowTest(apiUrl, auth, { + ...fields, + ...storageRef, + }); + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 404 || error.status === 405) + ) { + if (debug) { + out( + `[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`, + ); + } + + const testFormData = testSubmissionService.buildFormData(fields, buffer); + response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData); + } else { + throw error; + } } - const { message, results } = await ApiGateway.uploadFlow( - apiUrl, - auth, - testFormData, - ); + const { message, results } = response; if (debug) { - out(`[DEBUG] Flow upload response received`); + out(`[DEBUG] Flow submission response received`); out(`[DEBUG] Message: ${message}`); out(`[DEBUG] Results count: ${results?.length || 0}`); } diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 7d5ecbb..7ba37b4 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -12,7 +12,7 @@ import type { LiveSession, LiveSessionSummary, } from '../types/domain/live.types.js'; -import { paths } from '../types/generated/schema.types.js'; +import { components, paths } from '../types/generated/schema.types.js'; /** * Error thrown for non-OK API responses, carrying the HTTP status so callers @@ -513,6 +513,81 @@ export const ApiGateway = { } }, + /** + * Requests a storage URL for a client-direct flow zip upload. Mirrors + * `getBinaryUploadUrl` (same response shape) but stages the zip under + * `/tests/` instead of the app-binary path. A 404 here means the API + * predates the client-direct flow path — callers fall back to `uploadFlow`. + */ + async getFlowUploadUrl( + baseUrl: string, + auth: AuthContext, + fileSize: number, + ) { + try { + const res = await fetch(`${baseUrl}/uploads/getFlowUploadUrl`, { + body: JSON.stringify({ fileSize, useTus: true }), + headers: { + 'content-type': 'application/json', + ...auth.headers, + }, + method: 'POST', + }); + if (!res.ok) { + await this.handleApiError(res, 'Failed to get flow upload URL'); + } + + // Same response shape as getBinaryUploadUrl: { id, tempPath, finalPath, path, b2?, token? }. + return await parseJsonResponse< + components['schemas']['IGetBinaryUploadUrlResponse'] + >(res, 'Failed to get flow upload URL'); + } catch (error) { + if (error instanceof TypeError && error.message === 'fetch failed') { + throw this.enhanceFetchError(error, `${baseUrl}/uploads/getFlowUploadUrl`); + } + + throw error; + } + }, + + /** + * Submits a flow test that references an already-uploaded zip (JSON body, no + * multipart). The response is identical to the legacy `POST /uploads/flow`. + * A 404 means the API predates this endpoint — callers fall back to + * `uploadFlow`. + */ + async submitFlowTest( + baseUrl: string, + auth: AuthContext, + body: Record, + ) { + try { + const res = await fetch(`${baseUrl}/uploads/submitFlowTest`, { + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json', + ...auth.headers, + }, + method: 'POST', + }); + if (!res.ok) { + await this.handleApiError(res, 'Failed to submit test flows'); + } + + // Identical response to the legacy multipart POST /uploads/flow. + return await parseJsonResponse<{ + message?: string; + results?: components['schemas']['IDBResult'][]; + }>(res, 'Failed to submit test flows'); + } catch (error) { + if (error instanceof TypeError && error.message === 'fetch failed') { + throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`); + } + + throw error; + } + }, + /** * Generic report download method that handles both junit and allure reports diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index d05628a..1ba76f3 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -3,13 +3,13 @@ import * as path from 'node:path'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { ApiGateway } from '../../gateways/api-gateway.js'; +import { ApiError, ApiGateway } from '../../gateways/api-gateway.js'; import { plan } from '../../services/execution-plan.service.js'; import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js'; import { DeviceValidationService } from '../../services/device-validation.service.js'; import { TestSubmissionService } from '../../services/test-submission.service.js'; import { VersionService } from '../../services/version.service.js'; -import { uploadBinary, verifyAppZip } from '../../methods.js'; +import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js'; import { getCliVersion } from '../../utils/cli.js'; import { fetchCompatibilityData } from '../../utils/compatibility.js'; import { getConsoleUrl } from '../../utils/styling.js'; @@ -197,7 +197,8 @@ export function registerRunCloudTest(server: McpServer): void { } const { continueOnFailure = true } = executionPlan.sequence ?? {}; - const testFormData = await new TestSubmissionService().buildTestFormData({ + const testSubmissionService = new TestSubmissionService(); + const { buffer, fields } = await testSubmissionService.buildTestPayload({ appBinaryId, cliVersion, commonRoot, @@ -217,11 +218,33 @@ export function registerRunCloudTest(server: McpServer): void { logger: logStderr, }); - const { message, results } = await ApiGateway.uploadFlow( - apiUrl, - auth, - testFormData, - ); + // New path: upload the zip to storage, then submit a JSON test + // referencing it. Older API deployments lack these endpoints (404/405); + // fall back to the legacy multipart POST /uploads/flow. Mirrors + // `dcd cloud`. + let response: Awaited>; + try { + const storageRef = await uploadFlowZip({ apiUrl, auth, buffer }); + response = await ApiGateway.submitFlowTest(apiUrl, auth, { + ...fields, + ...storageRef, + }); + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 404 || error.status === 405) + ) { + const testFormData = testSubmissionService.buildFormData( + fields, + buffer, + ); + response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData); + } else { + throw error; + } + } + + const { message, results } = response; if (!results?.length) { throw new Error(`No tests were created: ${message}`); } diff --git a/src/methods.ts b/src/methods.ts index 15c125c..42ab30d 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -7,7 +7,7 @@ import { readdirSync, writeFileSync, } from 'node:fs'; -import { access, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { pipeline } from 'node:stream/promises'; @@ -764,6 +764,102 @@ async function performUpload(config: PerformUploadConfig): Promise { return id; } +/** + * Storage reference for an already-uploaded flow zip, threaded into the + * `submitFlowTest` JSON body so the API can locate the staged file. + */ +export interface FlowZipUploadResult { + backblazeSuccess: boolean; + bytes: number; + id: string; + path: string; + supabaseSuccess: boolean; + useTus: boolean; +} + +/** + * Uploads an already-built flow zip directly to storage, mirroring the binary + * client-direct path: getFlowUploadUrl → upload to storage (Backblaze if + * offered + TUS to Supabase) → return the storage reference for submitFlowTest. + * + * The zip arrives as an in-memory Buffer (from `compressFilesFromRelativePath`); + * it's written to a temp file so the exact same disk-streaming uploaders the + * binary path uses can be reused unchanged. `supabaseSuccess`/`backblazeSuccess` + * are reported honestly based on which uploads succeeded. + * + * Errors from `getFlowUploadUrl` propagate untouched so callers can detect a + * 404 from an older API and fall back to the legacy multipart endpoint. + */ +export const uploadFlowZip = async (config: { + apiUrl: string; + auth: AuthContext; + buffer: Buffer; + debug?: boolean; +}): Promise => { + const { apiUrl, auth, buffer, debug = false } = config; + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-flow-zip-')); + const diskPath = path.join(tempDir, 'flowFile.zip'); + + try { + await writeFile(diskPath, buffer); + + const source: UploadSource = { + contentType: 'application/zip', + diskPath, + name: 'flowFile.zip', + size: buffer.length, + }; + + // Same response shape as getBinaryUploadUrl. A 404 here means the API + // predates the client-direct flow path — let it propagate so the caller + // falls back to the multipart endpoint. + const { id, tempPath, finalPath, b2 } = await ApiGateway.getFlowUploadUrl( + apiUrl, + auth, + buffer.length, + ); + + if (!tempPath) throw new Error('No upload path provided by API'); + + const env = inferEnvFromApiUrl(apiUrl); + + // Upload to Backblaze first (primary, if configured) + const backblazeResult = await handleBackblazeUpload({ + auth, + apiUrl, + b2: b2 as { large?: unknown; simple?: unknown; strategy: string } | undefined, + debug, + finalPath, + source, + }); + let lastError = backblazeResult.error; + + // Always upload to Supabase (always-on alongside Backblaze) + const supabaseResult = await uploadToSupabase(env, tempPath, source, debug); + if (!supabaseResult.success && supabaseResult.error) { + lastError = supabaseResult.error; + } + + validateUploadResults(supabaseResult.success, backblazeResult.success, lastError, b2, debug); + + if (debug) { + console.log(`[DEBUG] Flow zip upload summary - Backblaze: ${backblazeResult.success ? '✓' : '✗'}, Supabase: ${supabaseResult.success ? '✓' : '✗'}`); + } + + return { + backblazeSuccess: backblazeResult.success, + bytes: buffer.length, + id, + path: tempPath, + supabaseSuccess: supabaseResult.success, + useTus: true, + }; + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +}; + /** * Upload file to Backblaze using signed URL (simple upload for files < 100MB) * @param uploadUrl - Backblaze upload URL diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 0f1c6c9..5d4aea6 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -46,13 +46,17 @@ const mimeTypeLookupByExtension: Record = { */ export class TestSubmissionService { /** - * Build FormData for test submission + * Build the test-submission payload: the compressed flow zip plus every + * non-`file` field, each encoded exactly as it is sent today. The same + * `fields` feed both the new JSON `submitFlowTest` body and the legacy + * multipart `buildFormData`, guaranteeing byte-identical field encoding + * across both paths. * @param config Test submission configuration - * @returns FormData ready to be submitted to the API + * @returns The flow zip buffer, its SHA-256, and the string-encoded fields */ - public async buildTestFormData( + public async buildTestPayload( config: TestSubmissionConfig, - ): Promise { + ): Promise<{ buffer: Buffer; fields: Record; sha: string }> { const { appBinaryId, flowFile, @@ -98,8 +102,6 @@ export class TestSubmissionService { const { flows: sequentialFlows = [] } = sequence ?? {}; - const testFormData = new FormData(); - const envObject = this.parseKeyValuePairs(env); const metadataObject = this.parseKeyValuePairs(metadata); @@ -159,30 +161,25 @@ export class TestSubmissionService { const sha = createHash('sha256').update(buffer).digest('hex'); this.logDebug(debug, logger, `[DEBUG] Flow ZIP SHA-256: ${sha}`); - const blob = new Blob([buffer as Uint8Array], { - type: mimeTypeLookupByExtension.zip, - }); + // String-encoded fields, in the same order and with the same encoding as + // the legacy multipart FormData. Reused verbatim by both submission paths. + const fields: Record = {}; - testFormData.set('file', blob, 'flowFile.zip'); - testFormData.set('sha', sha); - testFormData.set('appBinaryId', appBinaryId); - testFormData.set( - 'testFileNames', - JSON.stringify(this.normalizePaths(testFileNames, commonRoot)), + fields.sha = sha; + fields.appBinaryId = appBinaryId; + fields.testFileNames = JSON.stringify( + this.normalizePaths(testFileNames, commonRoot), ); - testFormData.set( - 'flowMetadata', - JSON.stringify(this.normalizePathMap(flowMetadata, commonRoot)), + fields.flowMetadata = JSON.stringify( + this.normalizePathMap(flowMetadata, commonRoot), ); - testFormData.set( - 'testFileOverrides', - JSON.stringify(this.normalizePathMap(flowOverrides, commonRoot)), + fields.testFileOverrides = JSON.stringify( + this.normalizePathMap(flowOverrides, commonRoot), ); - testFormData.set( - 'sequentialFlows', - JSON.stringify(this.normalizePaths(sequentialFlows, commonRoot)), + fields.sequentialFlows = JSON.stringify( + this.normalizePaths(sequentialFlows, commonRoot), ); - testFormData.set('env', JSON.stringify(envObject)); + fields.env = JSON.stringify(envObject); // Note: googlePlay is now included in configPayload below instead of as a separate field // to work around a FormData parsing issue in the API @@ -222,11 +219,11 @@ export class TestSubmissionService { version: cliVersion, }; - testFormData.set('config', JSON.stringify(configPayload)); + fields.config = JSON.stringify(configPayload); if (Object.keys(metadataObject).length > 0) { const metadataPayload = { userMetadata: metadataObject }; - testFormData.set('metadata', JSON.stringify(metadataPayload)); + fields.metadata = JSON.stringify(metadataPayload); this.logDebug( debug, logger, @@ -234,7 +231,7 @@ export class TestSubmissionService { ); } - this.setOptionalFields(testFormData, { + this.setOptionalFields(fields, { androidApiLevel, androidDevice, iOSDevice, @@ -244,10 +241,36 @@ export class TestSubmissionService { }); if (workspaceConfig) { - testFormData.set('workspaceConfig', JSON.stringify(workspaceConfig)); + fields.workspaceConfig = JSON.stringify(workspaceConfig); + } + + return { buffer, fields, sha }; + } + + /** + * Wraps the payload fields and flow zip into multipart FormData for the + * legacy `POST /uploads/flow` fallback. `file` is set first to preserve the + * exact part ordering the old code produced. + * @param fields String-encoded fields from {@link buildTestPayload} + * @param buffer The compressed flow zip + * @returns FormData ready to be submitted to the multipart API + */ + public buildFormData( + fields: Record, + buffer: Buffer, + ): FormData { + const formData = new FormData(); + + const blob = new Blob([buffer as Uint8Array], { + type: mimeTypeLookupByExtension.zip, + }); + formData.set('file', blob, 'flowFile.zip'); + + for (const [key, value] of Object.entries(fields)) { + formData.set(key, value); } - return testFormData; + return formData; } private logDebug( @@ -291,12 +314,12 @@ export class TestSubmissionService { } private setOptionalFields( - formData: FormData, + target: Record, fields: Record, ): void { for (const [key, value] of Object.entries(fields)) { if (value) { - formData.set(key, value.toString()); + target[key] = value.toString(); } } }