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
57 changes: 46 additions & 11 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof ApiGateway.submitFlowTest>>;
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}`);
}
Expand Down
77 changes: 76 additions & 1 deletion src/gateways/api-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
* `<orgId>/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<string, unknown>,
) {
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
Expand Down
39 changes: 31 additions & 8 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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<ReturnType<typeof ApiGateway.submitFlowTest>>;
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}`);
}
Expand Down
98 changes: 97 additions & 1 deletion src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -764,6 +764,102 @@ async function performUpload(config: PerformUploadConfig): Promise<string> {
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<FlowZipUploadResult> => {
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
Expand Down
Loading
Loading