@devicecloud.dev/dcd 5.0.0-beta.0 → 5.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/commands/artifacts.d.ts +28 -28
- package/dist/commands/artifacts.js +20 -23
- package/dist/commands/cloud.d.ts +57 -57
- package/dist/commands/cloud.js +224 -192
- package/dist/commands/list.d.ts +22 -22
- package/dist/commands/list.js +43 -40
- package/dist/commands/live.js +134 -127
- package/dist/commands/login.d.ts +11 -11
- package/dist/commands/login.js +46 -44
- package/dist/commands/logout.js +16 -18
- package/dist/commands/status.d.ts +11 -11
- package/dist/commands/status.js +53 -44
- package/dist/commands/switch-org.d.ts +7 -7
- package/dist/commands/switch-org.js +19 -21
- package/dist/commands/upgrade.js +41 -33
- package/dist/commands/upload.d.ts +10 -10
- package/dist/commands/upload.js +42 -43
- package/dist/commands/whoami.js +17 -20
- package/dist/config/environments.js +6 -12
- package/dist/config/flags/api.flags.js +1 -4
- package/dist/config/flags/binary.flags.js +1 -4
- package/dist/config/flags/device.flags.js +6 -9
- package/dist/config/flags/environment.flags.js +1 -4
- package/dist/config/flags/execution.flags.js +1 -4
- package/dist/config/flags/github.flags.js +1 -4
- package/dist/config/flags/output.flags.js +1 -4
- package/dist/constants.js +15 -18
- package/dist/gateways/api-gateway.d.ts +31 -6
- package/dist/gateways/api-gateway.js +70 -16
- package/dist/gateways/cli-auth-gateway.d.ts +1 -1
- package/dist/gateways/cli-auth-gateway.js +3 -6
- package/dist/gateways/realtime-gateway.d.ts +32 -0
- package/dist/gateways/realtime-gateway.js +103 -0
- package/dist/gateways/supabase-gateway.d.ts +1 -1
- package/dist/gateways/supabase-gateway.js +10 -14
- package/dist/index.js +41 -38
- package/dist/mcp/context.d.ts +33 -0
- package/dist/mcp/context.js +33 -0
- package/dist/mcp/helpers.d.ts +16 -0
- package/dist/mcp/helpers.js +34 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +24 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +27 -0
- package/dist/mcp/tools/download-artifacts.d.ts +11 -0
- package/dist/mcp/tools/download-artifacts.js +84 -0
- package/dist/mcp/tools/get-status.d.ts +7 -0
- package/dist/mcp/tools/get-status.js +39 -0
- package/dist/mcp/tools/list-devices.d.ts +7 -0
- package/dist/mcp/tools/list-devices.js +27 -0
- package/dist/mcp/tools/list-runs.d.ts +3 -0
- package/dist/mcp/tools/list-runs.js +60 -0
- package/dist/mcp/tools/run-cloud-test.d.ts +14 -0
- package/dist/mcp/tools/run-cloud-test.js +233 -0
- package/dist/methods.d.ts +32 -1
- package/dist/methods.js +133 -79
- package/dist/services/device-validation.service.d.ts +1 -1
- package/dist/services/device-validation.service.js +1 -5
- package/dist/services/execution-plan.service.js +14 -17
- package/dist/services/execution-plan.utils.js +15 -23
- package/dist/services/flow-paths.d.ts +17 -0
- package/dist/services/flow-paths.js +52 -0
- package/dist/services/metadata-extractor.service.js +22 -25
- package/dist/services/moropo.service.js +18 -20
- package/dist/services/report-download.service.d.ts +1 -1
- package/dist/services/report-download.service.js +5 -9
- package/dist/services/results-polling.service.d.ts +18 -3
- package/dist/services/results-polling.service.js +211 -108
- package/dist/services/telemetry.service.d.ts +10 -1
- package/dist/services/telemetry.service.js +40 -18
- package/dist/services/test-submission.service.d.ts +21 -4
- package/dist/services/test-submission.service.js +51 -34
- package/dist/services/version.service.d.ts +30 -7
- package/dist/services/version.service.js +88 -32
- package/dist/types/domain/auth.types.d.ts +8 -0
- package/dist/types/domain/auth.types.js +1 -2
- package/dist/types/domain/device.types.js +8 -11
- package/dist/types/domain/live.types.js +1 -2
- package/dist/types/generated/schema.types.js +1 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +2 -18
- package/dist/types.js +1 -2
- package/dist/utils/auth.d.ts +1 -1
- package/dist/utils/auth.js +27 -28
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +39 -0
- package/dist/utils/cli.d.ts +16 -2
- package/dist/utils/cli.js +57 -29
- package/dist/utils/compatibility.d.ts +1 -1
- package/dist/utils/compatibility.js +5 -7
- package/dist/utils/config-store.js +33 -43
- package/dist/utils/connectivity.js +1 -4
- package/dist/utils/expo.js +15 -21
- package/dist/utils/orgs.js +8 -12
- package/dist/utils/paths.js +2 -5
- package/dist/utils/progress.d.ts +3 -0
- package/dist/utils/progress.js +47 -8
- package/dist/utils/styling.d.ts +35 -37
- package/dist/utils/styling.js +52 -86
- package/dist/utils/ui.d.ts +41 -0
- package/dist/utils/ui.js +95 -0
- package/package.json +27 -24
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { getCliVersion } from '../utils/cli.js';
|
|
3
|
+
import { isReadOnly } from './context.js';
|
|
4
|
+
import { registerDownloadArtifacts } from './tools/download-artifacts.js';
|
|
5
|
+
import { registerGetStatus } from './tools/get-status.js';
|
|
6
|
+
import { registerListDevices } from './tools/list-devices.js';
|
|
7
|
+
import { registerListRuns } from './tools/list-runs.js';
|
|
8
|
+
import { registerRunCloudTest } from './tools/run-cloud-test.js';
|
|
9
|
+
/**
|
|
10
|
+
* Build the devicecloud.dev MCP server with its tool set registered. Read-only
|
|
11
|
+
* tools are always present; the billable `dcd_run_cloud_test` is omitted in
|
|
12
|
+
* read-only mode (`DCD_MCP_READONLY=1` or `--read-only`).
|
|
13
|
+
*/
|
|
14
|
+
export function createServer() {
|
|
15
|
+
const server = new McpServer({
|
|
16
|
+
name: 'devicecloud',
|
|
17
|
+
version: getCliVersion(),
|
|
18
|
+
});
|
|
19
|
+
registerListDevices(server);
|
|
20
|
+
registerListRuns(server);
|
|
21
|
+
registerGetStatus(server);
|
|
22
|
+
registerDownloadArtifacts(server);
|
|
23
|
+
if (!isReadOnly()) {
|
|
24
|
+
registerRunCloudTest(server);
|
|
25
|
+
}
|
|
26
|
+
return server;
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Download a completed run's artifacts (zip) and/or a formatted report to local
|
|
4
|
+
* disk. This reads cloud state and writes files locally — it does not change
|
|
5
|
+
* anything cloud-side, so it stays available in read-only mode.
|
|
6
|
+
*
|
|
7
|
+
* The underlying service swallows download failures into `warnLogger` rather
|
|
8
|
+
* than throwing (a missing-artifacts 404 isn't fatal), so we collect those
|
|
9
|
+
* warnings and return them — an empty `warnings` array means success.
|
|
10
|
+
*/
|
|
11
|
+
export declare function registerDownloadArtifacts(server: McpServer): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ReportDownloadService } from '../../services/report-download.service.js';
|
|
4
|
+
import { getContext, logStderr } from '../context.js';
|
|
5
|
+
import { jsonResult, runTool } from '../helpers.js';
|
|
6
|
+
/**
|
|
7
|
+
* Download a completed run's artifacts (zip) and/or a formatted report to local
|
|
8
|
+
* disk. This reads cloud state and writes files locally — it does not change
|
|
9
|
+
* anything cloud-side, so it stays available in read-only mode.
|
|
10
|
+
*
|
|
11
|
+
* The underlying service swallows download failures into `warnLogger` rather
|
|
12
|
+
* than throwing (a missing-artifacts 404 isn't fatal), so we collect those
|
|
13
|
+
* warnings and return them — an empty `warnings` array means success.
|
|
14
|
+
*/
|
|
15
|
+
export function registerDownloadArtifacts(server) {
|
|
16
|
+
server.registerTool('dcd_download_artifacts', {
|
|
17
|
+
title: 'Download run artifacts',
|
|
18
|
+
description: 'Download a completed test run\'s artifacts (videos/logs zip) and/or a formatted report to local disk. ' +
|
|
19
|
+
'Returns the resolved output paths and any warnings (a non-empty warnings array means a download could not be produced, e.g. no results yet).',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
uploadId: z.string().describe('UUID of the upload to download artifacts for'),
|
|
22
|
+
type: z
|
|
23
|
+
.enum(['ALL', 'FAILED'])
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Which tests to include artifacts for (default ALL)'),
|
|
26
|
+
artifactsPath: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Local path to write the artifacts zip (default ./artifacts.zip)'),
|
|
30
|
+
report: z
|
|
31
|
+
.enum(['junit', 'allure', 'html'])
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('Also download a formatted report of this type'),
|
|
34
|
+
reportPath: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('Local path for the report file (defaults depend on report type)'),
|
|
38
|
+
},
|
|
39
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
40
|
+
}, async (args) => runTool('dcd_download_artifacts', async () => {
|
|
41
|
+
const { apiUrl, auth } = await getContext();
|
|
42
|
+
const service = new ReportDownloadService();
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const warnLogger = (m) => {
|
|
45
|
+
warnings.push(m);
|
|
46
|
+
logStderr(m);
|
|
47
|
+
};
|
|
48
|
+
const artifactsPath = args.artifactsPath ?? './artifacts.zip';
|
|
49
|
+
await service.downloadArtifacts({
|
|
50
|
+
apiUrl,
|
|
51
|
+
auth,
|
|
52
|
+
uploadId: args.uploadId,
|
|
53
|
+
downloadType: args.type ?? 'ALL',
|
|
54
|
+
artifactsPath,
|
|
55
|
+
logger: logStderr,
|
|
56
|
+
warnLogger,
|
|
57
|
+
});
|
|
58
|
+
let reportPath;
|
|
59
|
+
if (args.report) {
|
|
60
|
+
reportPath = args.reportPath
|
|
61
|
+
? path.resolve(args.reportPath)
|
|
62
|
+
: path.resolve(args.report === 'junit'
|
|
63
|
+
? 'report.xml'
|
|
64
|
+
: 'report.html');
|
|
65
|
+
await service.downloadReports({
|
|
66
|
+
apiUrl,
|
|
67
|
+
auth,
|
|
68
|
+
uploadId: args.uploadId,
|
|
69
|
+
reportType: args.report,
|
|
70
|
+
junitPath: args.report === 'junit' ? reportPath : undefined,
|
|
71
|
+
allurePath: args.report === 'allure' ? reportPath : undefined,
|
|
72
|
+
htmlPath: args.report === 'html' ? reportPath : undefined,
|
|
73
|
+
logger: logStderr,
|
|
74
|
+
warnLogger,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return jsonResult({
|
|
78
|
+
uploadId: args.uploadId,
|
|
79
|
+
artifactsPath: path.resolve(artifactsPath),
|
|
80
|
+
reportPath,
|
|
81
|
+
warnings,
|
|
82
|
+
});
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Status of a single upload by id or name. This is the polling primitive: after
|
|
4
|
+
* an async `dcd_run_cloud_test`, an agent calls this until `status` leaves
|
|
5
|
+
* PENDING/QUEUED/RUNNING.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registerGetStatus(server: McpServer): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiGateway } from '../../gateways/api-gateway.js';
|
|
3
|
+
import { getContext } from '../context.js';
|
|
4
|
+
import { jsonResult, runTool } from '../helpers.js';
|
|
5
|
+
/**
|
|
6
|
+
* Status of a single upload by id or name. This is the polling primitive: after
|
|
7
|
+
* an async `dcd_run_cloud_test`, an agent calls this until `status` leaves
|
|
8
|
+
* PENDING/QUEUED/RUNNING.
|
|
9
|
+
*/
|
|
10
|
+
export function registerGetStatus(server) {
|
|
11
|
+
server.registerTool('dcd_get_status', {
|
|
12
|
+
title: 'Get test run status',
|
|
13
|
+
description: 'Get the status of a single upload (test run) by upload id or name. ' +
|
|
14
|
+
'Provide exactly one of uploadId or name. ' +
|
|
15
|
+
'Returns: { status, name, createdAt, tests: [{ name, status, durationSeconds, failReason }] }. ' +
|
|
16
|
+
'Poll this after an async run until status is no longer PENDING/QUEUED/RUNNING.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
uploadId: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('UUID of the upload (as returned by dcd_run_cloud_test or dcd_list_runs)'),
|
|
22
|
+
name: z.string().optional().describe('Name of the upload to look up'),
|
|
23
|
+
},
|
|
24
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
25
|
+
}, async (args) => runTool('dcd_get_status', async () => {
|
|
26
|
+
if (args.uploadId && args.name) {
|
|
27
|
+
throw new Error('Provide only one of uploadId or name, not both.');
|
|
28
|
+
}
|
|
29
|
+
if (!args.uploadId && !args.name) {
|
|
30
|
+
throw new Error('Provide one of uploadId or name.');
|
|
31
|
+
}
|
|
32
|
+
const { apiUrl, auth } = await getContext();
|
|
33
|
+
const status = await ApiGateway.getUploadStatus(apiUrl, auth, {
|
|
34
|
+
uploadId: args.uploadId,
|
|
35
|
+
name: args.name,
|
|
36
|
+
});
|
|
37
|
+
return jsonResult(status);
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Discovery tool: the matrix of devices, OS versions, and Maestro versions the
|
|
4
|
+
* org can run. Agents should call this before `dcd_run_cloud_test` so they pass
|
|
5
|
+
* valid `iosDevice` / `iosVersion` / `androidDevice` / `androidApiLevel` values.
|
|
6
|
+
*/
|
|
7
|
+
export declare function registerListDevices(server: McpServer): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { fetchCompatibilityData } from '../../utils/compatibility.js';
|
|
2
|
+
import { getContext } from '../context.js';
|
|
3
|
+
import { jsonResult, runTool } from '../helpers.js';
|
|
4
|
+
/**
|
|
5
|
+
* Discovery tool: the matrix of devices, OS versions, and Maestro versions the
|
|
6
|
+
* org can run. Agents should call this before `dcd_run_cloud_test` so they pass
|
|
7
|
+
* valid `iosDevice` / `iosVersion` / `androidDevice` / `androidApiLevel` values.
|
|
8
|
+
*/
|
|
9
|
+
export function registerListDevices(server) {
|
|
10
|
+
server.registerTool('dcd_list_devices', {
|
|
11
|
+
title: 'List available devices',
|
|
12
|
+
description: 'List the iOS and Android devices, OS versions, and Maestro versions available on devicecloud.dev. ' +
|
|
13
|
+
'Use this to discover valid device/version values before submitting a test run. ' +
|
|
14
|
+
'Returns: { ios, android, androidPlay, maestro }, where each platform maps OS version → supported device list.',
|
|
15
|
+
inputSchema: {},
|
|
16
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
17
|
+
}, async () => runTool('dcd_list_devices', async () => {
|
|
18
|
+
const { apiUrl, auth } = await getContext();
|
|
19
|
+
const data = await fetchCompatibilityData(apiUrl, auth);
|
|
20
|
+
return jsonResult({
|
|
21
|
+
ios: data.ios,
|
|
22
|
+
android: data.android,
|
|
23
|
+
androidPlay: data.androidPlay,
|
|
24
|
+
maestro: data.maestro,
|
|
25
|
+
});
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiGateway } from '../../gateways/api-gateway.js';
|
|
3
|
+
import { getContext } from '../context.js';
|
|
4
|
+
import { jsonResult, runTool } from '../helpers.js';
|
|
5
|
+
/** List recent flow uploads for the org, with optional filters + pagination. */
|
|
6
|
+
export function registerListRuns(server) {
|
|
7
|
+
server.registerTool('dcd_list_runs', {
|
|
8
|
+
title: 'List recent test runs',
|
|
9
|
+
description: 'List recent flow uploads (test runs) for your organization, most recent first. ' +
|
|
10
|
+
'Supports name (with * wildcard), date range, and pagination. ' +
|
|
11
|
+
'Returns: { uploads: [{ id, name, created_at, consoleUrl }], total, limit, offset }. ' +
|
|
12
|
+
'Use the returned id with dcd_get_status or dcd_download_artifacts.',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
name: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Filter by upload name; supports a * wildcard, e.g. "nightly-*"'),
|
|
18
|
+
from: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Only uploads created on or after this ISO 8601 date (e.g. 2024-01-01)'),
|
|
22
|
+
to: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Only uploads created on or before this ISO 8601 date'),
|
|
26
|
+
limit: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(100)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('Maximum uploads to return (default 20)'),
|
|
33
|
+
offset: z
|
|
34
|
+
.number()
|
|
35
|
+
.int()
|
|
36
|
+
.min(0)
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('Number of uploads to skip, for pagination (default 0)'),
|
|
39
|
+
},
|
|
40
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
41
|
+
}, async (args) => runTool('dcd_list_runs', async () => {
|
|
42
|
+
const { apiUrl, auth } = await getContext();
|
|
43
|
+
for (const [key, value] of [
|
|
44
|
+
['from', args.from],
|
|
45
|
+
['to', args.to],
|
|
46
|
+
]) {
|
|
47
|
+
if (value && Number.isNaN(Date.parse(value))) {
|
|
48
|
+
throw new Error(`Invalid --${key} date "${value}". Use ISO 8601 format (e.g. 2024-01-01).`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const response = await ApiGateway.listUploads(apiUrl, auth, {
|
|
52
|
+
name: args.name,
|
|
53
|
+
from: args.from,
|
|
54
|
+
to: args.to,
|
|
55
|
+
limit: args.limit ?? 20,
|
|
56
|
+
offset: args.offset ?? 0,
|
|
57
|
+
});
|
|
58
|
+
return jsonResult(response);
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Submit a Maestro flow (or directory of flows) to devicecloud.dev.
|
|
4
|
+
*
|
|
5
|
+
* This is the only state-changing / billable tool — it consumes test minutes,
|
|
6
|
+
* so it is hidden in read-only mode and annotated as non-read-only/destructive
|
|
7
|
+
* so clients can gate it behind confirmation. Defaults to async (submit and
|
|
8
|
+
* return the upload id); set `wait: true` to block until completion, bounded by
|
|
9
|
+
* `waitTimeoutSeconds`.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors the `dcd cloud` command's submission path but headless: no Expo URL
|
|
12
|
+
* download, mitm, GitHub metadata, or JSON-file output. Use the CLI for those.
|
|
13
|
+
*/
|
|
14
|
+
export declare function registerRunCloudTest(server: McpServer): void;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ApiError, ApiGateway } from '../../gateways/api-gateway.js';
|
|
4
|
+
import { plan } from '../../services/execution-plan.service.js';
|
|
5
|
+
import { computeCommonRoot, buildTestMetadataMap } from '../../services/flow-paths.js';
|
|
6
|
+
import { DeviceValidationService } from '../../services/device-validation.service.js';
|
|
7
|
+
import { TestSubmissionService } from '../../services/test-submission.service.js';
|
|
8
|
+
import { VersionService } from '../../services/version.service.js';
|
|
9
|
+
import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
|
|
10
|
+
import { getCliVersion } from '../../utils/cli.js';
|
|
11
|
+
import { fetchCompatibilityData } from '../../utils/compatibility.js';
|
|
12
|
+
import { getConsoleUrl } from '../../utils/styling.js';
|
|
13
|
+
import { getContext, logStderr } from '../context.js';
|
|
14
|
+
import { jsonResult, runTool } from '../helpers.js';
|
|
15
|
+
const SUPPORTED_APP_EXTENSIONS = ['.apk', '.app', '.zip'];
|
|
16
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
17
|
+
const TERMINAL_STATUSES = new Set(['PASSED', 'FAILED', 'CANCELLED', 'ERROR']);
|
|
18
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
/**
|
|
20
|
+
* Submit a Maestro flow (or directory of flows) to devicecloud.dev.
|
|
21
|
+
*
|
|
22
|
+
* This is the only state-changing / billable tool — it consumes test minutes,
|
|
23
|
+
* so it is hidden in read-only mode and annotated as non-read-only/destructive
|
|
24
|
+
* so clients can gate it behind confirmation. Defaults to async (submit and
|
|
25
|
+
* return the upload id); set `wait: true` to block until completion, bounded by
|
|
26
|
+
* `waitTimeoutSeconds`.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors the `dcd cloud` command's submission path but headless: no Expo URL
|
|
29
|
+
* download, mitm, GitHub metadata, or JSON-file output. Use the CLI for those.
|
|
30
|
+
*/
|
|
31
|
+
export function registerRunCloudTest(server) {
|
|
32
|
+
server.registerTool('dcd_run_cloud_test', {
|
|
33
|
+
title: 'Run a cloud test',
|
|
34
|
+
description: 'Submit a Maestro flow (or a directory of flows) to run on devicecloud.dev. ' +
|
|
35
|
+
'Consumes test minutes (billable). Provide a flowFile plus either appFile (a local .apk/.app/.zip) or appBinaryId (a previously uploaded binary). ' +
|
|
36
|
+
'Defaults to async: returns { uploadId, consoleUrl, status: "PENDING", tests } immediately — poll dcd_get_status with the uploadId. ' +
|
|
37
|
+
'Set wait: true to block until the run finishes (bounded by waitTimeoutSeconds). ' +
|
|
38
|
+
'Set dryRun: true to preview which flows would run without submitting.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
flowFile: z
|
|
41
|
+
.string()
|
|
42
|
+
.describe('Path to a Maestro flow .yaml/.yml file, or a directory of flows'),
|
|
43
|
+
appFile: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe('Path to a local app binary (.apk, .app, or .zip). Mutually exclusive with appBinaryId.'),
|
|
47
|
+
appBinaryId: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('ID of a previously uploaded binary. Mutually exclusive with appFile.'),
|
|
51
|
+
iosVersion: z.string().optional().describe('iOS version, e.g. "17"'),
|
|
52
|
+
iosDevice: z.string().optional().describe('iOS device, e.g. "iphone-14"'),
|
|
53
|
+
androidApiLevel: z.string().optional().describe('Android API level, e.g. "34"'),
|
|
54
|
+
androidDevice: z.string().optional().describe('Android device, e.g. "pixel-7"'),
|
|
55
|
+
googlePlay: z.boolean().optional().describe('Use a Google Play-enabled Android image'),
|
|
56
|
+
name: z.string().optional().describe('A name for this run'),
|
|
57
|
+
env: z
|
|
58
|
+
.array(z.string())
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Environment variables as KEY=VALUE strings'),
|
|
61
|
+
includeTags: z.array(z.string()).optional().describe('Only run flows with these tags'),
|
|
62
|
+
excludeTags: z.array(z.string()).optional().describe('Skip flows with these tags'),
|
|
63
|
+
excludeFlows: z.array(z.string()).optional().describe('Flow paths/patterns to exclude'),
|
|
64
|
+
maestroVersion: z.string().optional().describe('Pin a specific Maestro version'),
|
|
65
|
+
retry: z.number().int().min(0).max(2).optional().describe('Auto-retry failed tests (max 2)'),
|
|
66
|
+
runnerType: z
|
|
67
|
+
.enum(['default', 'm4', 'm1', 'gpu1', 'cpu1'])
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Runner type (default "default")'),
|
|
70
|
+
configFile: z.string().optional().describe('Path to a workspace config.yaml'),
|
|
71
|
+
ignoreShaCheck: z
|
|
72
|
+
.boolean()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe('Force re-upload of the binary even if an identical one exists'),
|
|
75
|
+
dryRun: z.boolean().optional().describe('Preview flows that would run without submitting'),
|
|
76
|
+
wait: z
|
|
77
|
+
.boolean()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe('Block until the run completes instead of returning immediately (default false)'),
|
|
80
|
+
waitTimeoutSeconds: z
|
|
81
|
+
.number()
|
|
82
|
+
.int()
|
|
83
|
+
.min(30)
|
|
84
|
+
.max(3600)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('Max seconds to wait when wait is true (default 600)'),
|
|
87
|
+
},
|
|
88
|
+
annotations: {
|
|
89
|
+
title: 'Run a cloud test',
|
|
90
|
+
readOnlyHint: false,
|
|
91
|
+
destructiveHint: true,
|
|
92
|
+
openWorldHint: true,
|
|
93
|
+
},
|
|
94
|
+
}, async (args) => runTool('dcd_run_cloud_test', async () => {
|
|
95
|
+
if (args.appFile && args.appBinaryId) {
|
|
96
|
+
throw new Error('Provide only one of appFile or appBinaryId, not both.');
|
|
97
|
+
}
|
|
98
|
+
const { apiUrl, auth } = await getContext();
|
|
99
|
+
const cliVersion = getCliVersion();
|
|
100
|
+
const compatibilityData = await fetchCompatibilityData(apiUrl, auth);
|
|
101
|
+
const deviceValidation = new DeviceValidationService();
|
|
102
|
+
deviceValidation.validateiOSDevice(args.iosVersion, args.iosDevice, compatibilityData, { logger: logStderr });
|
|
103
|
+
deviceValidation.validateAndroidDevice(args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, { logger: logStderr });
|
|
104
|
+
const resolvedMaestroVersion = new VersionService().resolveMaestroVersion(args.maestroVersion, compatibilityData, { logger: logStderr });
|
|
105
|
+
// Directory inputs must end in a separator so the planner treats them
|
|
106
|
+
// as a workspace rather than a single file.
|
|
107
|
+
let flowFile = path.resolve(args.flowFile);
|
|
108
|
+
if (!flowFile.endsWith('.yaml') &&
|
|
109
|
+
!flowFile.endsWith('.yml') &&
|
|
110
|
+
!flowFile.endsWith('/')) {
|
|
111
|
+
flowFile += '/';
|
|
112
|
+
}
|
|
113
|
+
const executionPlan = await plan({
|
|
114
|
+
input: flowFile,
|
|
115
|
+
includeTags: args.includeTags ?? [],
|
|
116
|
+
excludeTags: args.excludeTags ?? [],
|
|
117
|
+
excludeFlows: args.excludeFlows,
|
|
118
|
+
configFile: args.configFile,
|
|
119
|
+
});
|
|
120
|
+
const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles);
|
|
121
|
+
const testMetadataMap = buildTestMetadataMap(executionPlan.flowMetadata, commonRoot);
|
|
122
|
+
if (args.dryRun) {
|
|
123
|
+
return jsonResult({
|
|
124
|
+
dryRun: true,
|
|
125
|
+
flows: Object.entries(testMetadataMap).map(([file, meta]) => ({
|
|
126
|
+
file,
|
|
127
|
+
flowName: meta.flowName,
|
|
128
|
+
tags: meta.tags,
|
|
129
|
+
})),
|
|
130
|
+
sequentialFlowCount: executionPlan.sequence?.flows.length ?? 0,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Resolve the binary: existing id, or upload the local file.
|
|
134
|
+
let appBinaryId = args.appBinaryId;
|
|
135
|
+
if (!appBinaryId) {
|
|
136
|
+
if (!args.appFile) {
|
|
137
|
+
throw new Error('Provide either appFile or appBinaryId.');
|
|
138
|
+
}
|
|
139
|
+
if (!SUPPORTED_APP_EXTENSIONS.some((ext) => args.appFile.endsWith(ext))) {
|
|
140
|
+
throw new Error(`App file must be one of: ${SUPPORTED_APP_EXTENSIONS.join(', ')} (got ${args.appFile}).`);
|
|
141
|
+
}
|
|
142
|
+
if (args.appFile.endsWith('.zip')) {
|
|
143
|
+
await verifyAppZip(args.appFile);
|
|
144
|
+
}
|
|
145
|
+
appBinaryId = await uploadBinary({
|
|
146
|
+
auth,
|
|
147
|
+
apiUrl,
|
|
148
|
+
filePath: args.appFile,
|
|
149
|
+
ignoreShaCheck: Boolean(args.ignoreShaCheck),
|
|
150
|
+
log: false,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const { continueOnFailure = true } = executionPlan.sequence ?? {};
|
|
154
|
+
const testSubmissionService = new TestSubmissionService();
|
|
155
|
+
const { buffer, fields } = await testSubmissionService.buildTestPayload({
|
|
156
|
+
appBinaryId,
|
|
157
|
+
cliVersion,
|
|
158
|
+
commonRoot,
|
|
159
|
+
continueOnFailure,
|
|
160
|
+
executionPlan,
|
|
161
|
+
flowFile,
|
|
162
|
+
env: args.env ?? [],
|
|
163
|
+
googlePlay: Boolean(args.googlePlay),
|
|
164
|
+
androidApiLevel: args.androidApiLevel,
|
|
165
|
+
androidDevice: args.androidDevice,
|
|
166
|
+
iOSDevice: args.iosDevice,
|
|
167
|
+
iOSVersion: args.iosVersion,
|
|
168
|
+
name: args.name,
|
|
169
|
+
runnerType: args.runnerType ?? 'default',
|
|
170
|
+
maestroVersion: resolvedMaestroVersion,
|
|
171
|
+
retry: args.retry,
|
|
172
|
+
logger: logStderr,
|
|
173
|
+
});
|
|
174
|
+
// New path: upload the zip to storage, then submit a JSON test
|
|
175
|
+
// referencing it. Older API deployments lack these endpoints (404/405);
|
|
176
|
+
// fall back to the legacy multipart POST /uploads/flow. Mirrors
|
|
177
|
+
// `dcd cloud`.
|
|
178
|
+
let response;
|
|
179
|
+
try {
|
|
180
|
+
const storageRef = await uploadFlowZip({ apiUrl, auth, buffer });
|
|
181
|
+
response = await ApiGateway.submitFlowTest(apiUrl, auth, {
|
|
182
|
+
...fields,
|
|
183
|
+
...storageRef,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof ApiError &&
|
|
188
|
+
(error.status === 404 || error.status === 405)) {
|
|
189
|
+
const testFormData = testSubmissionService.buildFormData(fields, buffer);
|
|
190
|
+
response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const { message, results } = response;
|
|
197
|
+
if (!results?.length) {
|
|
198
|
+
throw new Error(`No tests were created: ${message}`);
|
|
199
|
+
}
|
|
200
|
+
const uploadId = results[0].test_upload_id;
|
|
201
|
+
const consoleUrl = getConsoleUrl(apiUrl, uploadId, results[0].id);
|
|
202
|
+
const tests = results.map((r) => ({
|
|
203
|
+
fileName: r.test_file_name,
|
|
204
|
+
flowName: testMetadataMap[r.test_file_name]?.flowName ||
|
|
205
|
+
path.parse(r.test_file_name).name,
|
|
206
|
+
tags: testMetadataMap[r.test_file_name]?.tags ?? [],
|
|
207
|
+
status: r.status,
|
|
208
|
+
}));
|
|
209
|
+
if (!args.wait) {
|
|
210
|
+
return jsonResult({ uploadId, consoleUrl, status: 'PENDING', tests, message });
|
|
211
|
+
}
|
|
212
|
+
// Bounded poll. The run continues in the cloud regardless of timeout —
|
|
213
|
+
// the caller can resume with dcd_get_status using the returned uploadId.
|
|
214
|
+
const deadline = Date.now() + (args.waitTimeoutSeconds ?? 600) * 1000;
|
|
215
|
+
for (;;) {
|
|
216
|
+
const status = await ApiGateway.getUploadStatus(apiUrl, auth, { uploadId });
|
|
217
|
+
if (TERMINAL_STATUSES.has(status.status)) {
|
|
218
|
+
return jsonResult({ uploadId, consoleUrl, status: status.status, tests: status.tests });
|
|
219
|
+
}
|
|
220
|
+
if (Date.now() + POLL_INTERVAL_MS >= deadline) {
|
|
221
|
+
return jsonResult({
|
|
222
|
+
uploadId,
|
|
223
|
+
consoleUrl,
|
|
224
|
+
status: status.status,
|
|
225
|
+
timedOut: true,
|
|
226
|
+
tests: status.tests,
|
|
227
|
+
message: `Still running after ${args.waitTimeoutSeconds ?? 600}s; poll dcd_get_status with uploadId ${uploadId}.`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
await sleep(POLL_INTERVAL_MS);
|
|
231
|
+
}
|
|
232
|
+
}));
|
|
233
|
+
}
|
package/dist/methods.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuthContext } from './types/domain/auth.types';
|
|
1
|
+
import type { AuthContext } from './types/domain/auth.types.js';
|
|
2
2
|
export declare const compressFilesFromRelativePath: (basePath: string, files: string[], commonRoot: string) => Promise<Buffer>;
|
|
3
3
|
export declare const verifyAppZip: (zipPath: string) => Promise<void>;
|
|
4
4
|
interface UploadBinaryConfig {
|
|
@@ -10,6 +10,37 @@ interface UploadBinaryConfig {
|
|
|
10
10
|
log?: boolean;
|
|
11
11
|
}
|
|
12
12
|
export declare const uploadBinary: (config: UploadBinaryConfig) => Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Storage reference for an already-uploaded flow zip, threaded into the
|
|
15
|
+
* `submitFlowTest` JSON body so the API can locate the staged file.
|
|
16
|
+
*/
|
|
17
|
+
export interface FlowZipUploadResult {
|
|
18
|
+
backblazeSuccess: boolean;
|
|
19
|
+
bytes: number;
|
|
20
|
+
id: string;
|
|
21
|
+
path: string;
|
|
22
|
+
supabaseSuccess: boolean;
|
|
23
|
+
useTus: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Uploads an already-built flow zip directly to storage, mirroring the binary
|
|
27
|
+
* client-direct path: getFlowUploadUrl → upload to storage (Backblaze if
|
|
28
|
+
* offered + TUS to Supabase) → return the storage reference for submitFlowTest.
|
|
29
|
+
*
|
|
30
|
+
* The zip arrives as an in-memory Buffer (from `compressFilesFromRelativePath`);
|
|
31
|
+
* it's written to a temp file so the exact same disk-streaming uploaders the
|
|
32
|
+
* binary path uses can be reused unchanged. `supabaseSuccess`/`backblazeSuccess`
|
|
33
|
+
* are reported honestly based on which uploads succeeded.
|
|
34
|
+
*
|
|
35
|
+
* Errors from `getFlowUploadUrl` propagate untouched so callers can detect a
|
|
36
|
+
* 404 from an older API and fall back to the legacy multipart endpoint.
|
|
37
|
+
*/
|
|
38
|
+
export declare const uploadFlowZip: (config: {
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
auth: AuthContext;
|
|
41
|
+
buffer: Buffer;
|
|
42
|
+
debug?: boolean;
|
|
43
|
+
}) => Promise<FlowZipUploadResult>;
|
|
13
44
|
/**
|
|
14
45
|
* Writes JSON data to a file with error handling
|
|
15
46
|
* @param filePath - Path to the output JSON file
|