diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 1cb3bc1..961eb4a 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -36,7 +36,7 @@ jobs: lint-and-test: runs-on: ubuntu-latest - # The mock-api lives in the private moropo-com/dcd repo, checked out via an + # The mock-api lives in the private devicecloud-dev/dcd repo, checked out via an # SSH deploy key. GitHub does NOT expose secrets to pull_request workflows # triggered from forks, so that checkout (and the integration tests that need # it) can only run for same-repo events. Fork PRs still run lint/typecheck/build. @@ -57,7 +57,7 @@ jobs: if: env.HAS_PRIVATE_ACCESS == 'true' uses: actions/checkout@v7 with: - repository: moropo-com/dcd + repository: devicecloud-dev/dcd path: dcd ssh-key: ${{ secrets.DCD_SSH_DEPLOY_KEY }} # api/swagger.json is a file, which cone-mode sparse checkout rejects @@ -106,7 +106,7 @@ jobs: - name: Skip integration tests (fork PR — no mock-api access) if: env.HAS_PRIVATE_ACCESS != 'true' - run: echo "::notice::Integration tests skipped — the mock-api (private moropo-com/dcd) is not accessible from fork PRs. Lint, typecheck, and build still ran." + run: echo "::notice::Integration tests skipped — the mock-api (private devicecloud-dev/dcd) is not accessible from fork PRs. Lint, typecheck, and build still ran." - name: Build CLI working-directory: ./cli diff --git a/CLAUDE.md b/CLAUDE.md index 426a23a..985e4a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands - `pnpm dcd ` — run the CLI from source via `tsx`. -- `pnpm build` — clean, compile TypeScript to `dist/`, and `chmod +x dist/index.js` so the published binary is directly executable. +- `pnpm build` — clean, compile TypeScript to `dist/`, and `chmod +x dist/index.js dist/mcp/index.js` so both published binaries are directly executable. +- `pnpm build:binaries` — `scripts/build-binaries.mjs` produces the bun-compiled, self-contained `dcd--` binaries published to GitHub Releases (the install `dcd upgrade` self-updates). The platform/arch keys must stay in sync with `ASSET_BY_PLATFORM` in `src/commands/upgrade.ts`. - `pnpm lint` — ESLint over `src/` and `test/`. -- `pnpm typecheck` — `tsc --noEmit` over `src/` and `test/` (strict mode; `pnpm build` only compiles `src/`). -- `pnpm test` — runs `scripts/test-runner.mjs`: builds the CLI, boots a mock API, then runs all `test/**/*.test.ts` via mocha + ts-node. The mock API lives in the **sibling `dcd/` repo** (`../dcd/mock-api`). Override its location with `MOCK_API_DIR=/path/to/mock-api`. -- Run a single test: `pnpm mocha test/integration/cloud.integration.test.ts --timeout 60000` (picks up `.mocharc.json` which wires tsx; requires the mock API already running on its expected port). +- `pnpm typecheck` — `tsc --noEmit -p tsconfig.test.json` over `src/` and `test/` (strict mode; `pnpm build` only compiles `src/`). Requires Node `>=22`. +- `pnpm test` — runs `scripts/test-runner.mjs`: builds the CLI, boots the mock API, then runs all `test/**/*.test.ts` via mocha. TypeScript is loaded by **tsx** (`.mocharc.json`'s `node-option: ["import=tsx"]`), *not* ts-node — Mocha 11 imports specs as ESM, which bypasses the `require: ts-node/register` hook. The mock API lives in the **sibling `dcd/` repo** (`../dcd/mock-api`, started via `npm run start:auth` on port 3001). Override its location with `MOCK_API_DIR=/path/to/mock-api`. The runner isolates `DCD_CONFIG_DIR` to a temp dir so tests never touch your real `dcd login` session. +- Tests split into `test/unit/*` (pure, no backend) and `test/integration/*` (drive the built CLI against the mock API). Run a single test: `pnpm mocha test/integration/cloud.integration.test.ts --timeout 60000` (picks up `.mocharc.json` which wires tsx; integration specs require the mock API already running on port 3001). ## Entry point @@ -19,7 +20,7 @@ The package ships a **second bin, `dcd-mcp`** (`bin.dcd-mcp` → `dist/mcp/index ## Architecture -Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`. +Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, `upgrade`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`; `upgrade` self-updates the standalone bun binary (no-op for npm installs, which it redirects to `npm install -g`). Note `src/index.ts` deliberately **reimplements** citty's `runMain` rather than calling it — see the Telemetry section for why. **Flag composition.** Flag definitions are split by domain in `src/config/flags/*.flags.ts` (api, binary, device, environment, execution, github, output) and re-exported as a single `flags` object from `src/constants.ts`. Commands that need the full cloud surface spread `...flags` into their citty `args`; subset commands import individual flag groups. @@ -45,3 +46,29 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl **Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`. **MCP server.** `src/mcp/` is a third front-end onto the same service layer (a sibling to `src/commands/`), shipped as the `dcd-mcp` bin over stdio transport (`@modelcontextprotocol/sdk`, `zod` schemas). `index.ts` boots the server; `server.ts` registers tools; `context.ts` resolves auth + API URL **lazily and once** (so `tools/list` works unauthenticated and auth errors surface as tool errors, not a boot crash) via the same `resolveAuth`/`resolveApiUrl` as the CLI — `DEVICE_CLOUD_API_KEY` env or stored `dcd login` session, with `DCD_API_URL` to override. **Critical invariant: stdout is the JSON-RPC channel** — tools must never call the `src/commands/*` layer or `utils/cli` `logger` (both write to stdout / can `process.exit`); they call services/gateways directly with `logStderr` and return data via `helpers.ts` `jsonResult`/`errorResult`. The `runTool` wrapper records `mcp tool …` telemetry and converts thrown errors to `isError` results. Tools: `dcd_list_devices`, `dcd_list_runs`, `dcd_get_status`, `dcd_download_artifacts` (read-only), and `dcd_run_cloud_test` (billable — gated out by `--read-only` / `DCD_MCP_READONLY=1`, annotated destructive, async-by-default). `dcd_run_cloud_test` reuses `computeCommonRoot`/`buildTestMetadataMap` from `src/services/flow-paths.ts` (extracted from `cloud.ts` so both build identical server-side paths). Registry manifest: `server.json` at repo root. + +## Contributing + +Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release): + +- **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly. +- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it. +- Type → bump (pre-1.0, so `feat` and breaking `!` both bump **minor**): `feat` minor; `fix`/`perf`/`deps`/`revert`/`refactor` patch; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form. +- **Never hand-edit `package.json` version, `CHANGELOG.md`, or the `.release-please-manifest*.json` files** — release-please owns all of them. `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). +- A first-time contributor must sign the CLA (the CLA Assistant bot comments on the first PR); the CLA check must be green to merge. +- **CI (`.github/workflows/cli-ci.yml`) runs on every PR** including forks: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm build`, `pnpm audit --audit-level moderate`. The **integration tests need the private `devicecloud-dev/dcd` mock-api** (cloned via the `DCD_SSH_DEPLOY_KEY` secret), and GitHub withholds secrets from fork and Dependabot PRs — so `pnpm test` is **skipped there** and a maintainer runs the full suite before merge. gitleaks also runs as a pre-commit hook (allowlist in `.gitleaks.toml`); without the binary installed the hook self-skips and CI is the backstop. + +## Releases + +Fully automated by [release-please](https://github.com/googleapis/release-please) — no manual version bumping. `.github/workflows/release-please.yml` drives **two parallel tracks off two separate config+manifest pairs**: + +| Push to | Track | Config / manifest | Version | npm tag | +| --- | --- | --- | --- | --- | +| `dev` | **beta** (prerelease) | `release-please-config-beta.json` / `.release-please-manifest-beta.json` | `X.Y.Z-beta.N` | `beta` | +| `production` | **stable** | `release-please-config.json` / `.release-please-manifest.json` | `X.Y.Z` | `latest` | + +The two manifests track their versions **independently** (e.g. beta `5.0.0-beta.3` while stable is `5.0.0`). On each qualifying push release-please opens/updates a **Release PR** on that branch; merging the Release PR creates the git tag + GitHub Release, and the same workflow run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into: +1. `npm-publish.yml` — publishes to npm. Guards: a prod publish may only run from `production` and its version must **not** carry `-beta`; a beta version **must** carry `-beta`. +2. `release-binaries.yml` — bun-compiles the standalone binaries (`node scripts/build-binaries.mjs`) and uploads them to the GitHub Release. `get.devicecloud.dev` serves them by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. + +**Promoting beta → stable** is a maintainer merging `dev` into `production` via a PR (also gated by `cli-ci.yml`) — that push to `production` is what triggers the stable Release PR. The current `release/promote-v5` branch is exactly such a promotion. Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR triggers the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured. diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index e8358cf..db1c0fb 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -33,7 +33,7 @@ import { EiOSVersions, } from '../types/domain/device.types.js'; import { resolveAuth } from '../utils/auth.js'; -import { isCI } from '../utils/ci.js'; +import { detectCiContext, isCI } from '../utils/ci.js'; import { CliError, coerceArray, @@ -48,6 +48,7 @@ import { CompatibilityData, fetchCompatibilityData, } from '../utils/compatibility.js'; +import { renderNotices } from '../services/notices.service.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js'; import { @@ -346,9 +347,14 @@ export const cloudCommand = defineCommand({ ); } + const ciContext = detectCiContext(); let compatibilityData: CompatibilityData; try { - compatibilityData = await fetchCompatibilityData(apiUrl, auth); + compatibilityData = await fetchCompatibilityData(apiUrl, auth, { + cliVersion, + ciProvider: ciContext.provider, + ciWrapperVersion: ciContext.wrapperVersion, + }); if (debug) { out('[DEBUG] Successfully fetched compatibility data from API'); } @@ -378,20 +384,6 @@ export const cloudCommand = defineCommand({ }, ); - // Soft deprecation notice for Maestro versions slated for removal on - // 26 June 2026. Non-fatal — these still run during the grace period. - const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0']; - if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) { - warnOut(ui.warn(colors.bold(`Maestro ${resolvedMaestroVersion} is deprecated`))); - warnOut( - ui.branch([ - `Maestro ${resolvedMaestroVersion} will be removed on 26 June 2026; after that, tests pinned to it will fail.`, - 'Upgrade to Maestro 2.6.0 or above.', - `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/configuration/maestro-versions')}`, - ]), - ); - } - if (retry !== undefined && retry > 2) { out( ui.warn( @@ -486,20 +478,21 @@ export const cloudCommand = defineCommand({ logger: (m: string) => out(m), }); - // iOS 16 deprecation notice (soft warning during the grace period; - // removed on 23 August 2026). Only fires on an explicit --ios-version 16 — - // when omitted the API defaults to iOS 17, so no false warning. - const DEPRECATED_IOS_VERSIONS = ['16']; - if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) { - warnOut(ui.warn(colors.bold('iOS 16 is deprecated'))); - warnOut( - ui.branch([ - 'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.', - 'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.', - `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`, - ]), - ); - } + // Render DB-driven notices (deprecation/warn/info/marketing) the API + // returned with the compatibility data. Replaces the previously hardcoded + // iOS-16 deprecation warning — that is now a seeded notice gated on the + // selected iOS version below. Honours --json via out/warnOut. + renderNotices( + compatibilityData.notices, + { + ios_version: iOSVersion, + android_api_level: androidApiLevel, + cli_version: cliVersion, + ci_provider: ciContext.provider, + ci_wrapper_version: ciContext.wrapperVersion, + }, + { out }, + ); deviceValidationService.validateAndroidDevice( androidApiLevel, diff --git a/src/services/notices.service.ts b/src/services/notices.service.ts new file mode 100644 index 0000000..9a8070d --- /dev/null +++ b/src/services/notices.service.ts @@ -0,0 +1,145 @@ +import { ui } from '../utils/ui.js'; +import { colors } from '../utils/styling.js'; +import { compareSemver } from './version.service.js'; + +export type NoticeLevel = 'deprecation' | 'warn' | 'info' | 'marketing'; + +export interface NoticeMatchRule { + field: string; + op: 'present' | 'absent' | 'equals' | 'in' | 'not_in' | 'lt' | 'gt'; + value?: unknown; +} + +export interface NoticeMatch { + rules: NoticeMatchRule[]; +} + +/** + * The client-facing notice shape returned by the API (embedded in the + * compatibility response and from GET /notices). `match` is an optional display + * gate the CLI evaluates locally against its own context (e.g. the selected iOS + * version) — the API can't know those at fetch time. + */ +export interface Notice { + id: string; + slug: string | null; + level: NoticeLevel; + title: string; + body: string; + learnMoreUrl: string | null; + dismissible: boolean; + match: NoticeMatch | null; +} + +/** Flat key/value context the `match` DSL is evaluated against. */ +export type NoticeContext = Record; + +function isPresent(raw: unknown): boolean { + return raw !== null && raw !== undefined && raw !== false && raw !== ''; +} + +function ruleMatches(rule: NoticeMatchRule, ctx: NoticeContext): boolean { + const raw = ctx[rule.field]; + switch (rule.op) { + case 'present': + return isPresent(raw); + case 'absent': + return !isPresent(raw); + case 'equals': + return isPresent(raw) && String(raw) === String(rule.value); + case 'in': + return ( + isPresent(raw) && + Array.isArray(rule.value) && + rule.value.map(String).includes(String(raw)) + ); + case 'not_in': + return ( + isPresent(raw) && + Array.isArray(rule.value) && + !rule.value.map(String).includes(String(raw)) + ); + case 'lt': + return ( + isPresent(raw) && + rule.value != null && + compareSemver(String(raw), String(rule.value)) < 0 + ); + case 'gt': + return ( + isPresent(raw) && + rule.value != null && + compareSemver(String(raw), String(rule.value)) > 0 + ); + default: + return false; + } +} + +/** A null / empty match imposes no constraint; otherwise every rule must match (AND). */ +export function matchesRules( + match: NoticeMatch | null | undefined, + ctx: NoticeContext, +): boolean { + if (!match || !Array.isArray(match.rules) || match.rules.length === 0) { + return true; + } + return match.rules.every((rule) => ruleMatches(rule, ctx)); +} + +export interface RenderNoticesOptions { + /** + * Emit a line of human output. Notices route through the same gated `out` as + * the rest of the CLI (suppressed under `--json`); we don't use the `warn` + * channel because its logger prepends its own `⚠`, which would double up with + * the symbol the `ui.*` helpers already add. + */ + out: (message: string) => void; +} + +/** Render a single notice with styling appropriate to its level. */ +function renderNotice(notice: Notice, opts: RenderNoticesOptions): void { + const rows = [notice.body]; + if (notice.learnMoreUrl) { + rows.push(`${colors.dim('See:')} ${colors.url(notice.learnMoreUrl)}`); + } + + switch (notice.level) { + case 'deprecation': + opts.out(ui.deprecation(colors.bold(notice.title))); + opts.out(ui.branch(rows)); + break; + case 'warn': + opts.out(ui.warn(colors.bold(notice.title))); + opts.out(ui.branch(rows)); + break; + case 'marketing': + opts.out(ui.section(notice.title)); + opts.out(ui.branch(rows)); + break; + case 'info': + default: + opts.out(ui.info(colors.bold(notice.title))); + opts.out(ui.branch(rows)); + break; + } +} + +/** + * Filter notices by their local-context `match` gate and render those that pass. + * Returns the visible notices so a `--json` caller can include them in its + * payload instead of printing. `opts.out` is the caller's `--json`-gated + * emitter, so under `--json` nothing prints but the list is still returned. + */ +export function renderNotices( + notices: Notice[] | undefined, + ctx: NoticeContext, + opts: RenderNoticesOptions, +): Notice[] { + if (!notices || notices.length === 0) return []; + const visible = notices.filter((n) => matchesRules(n.match, ctx)); + for (const notice of visible) { + renderNotice(notice, opts); + } + return visible; +} diff --git a/src/services/version.service.ts b/src/services/version.service.ts index 1ed8239..2340377 100644 --- a/src/services/version.service.ts +++ b/src/services/version.service.ts @@ -27,7 +27,7 @@ export type LatestVersionResult = * lexically (ASCII), and numeric always sorts below alphanumeric. A longer * set of identifiers wins when all preceding ones are equal. */ -function compareSemver(a: string, b: string): number { +export function compareSemver(a: string, b: string): number { const split = (v: string): { release: number[]; pre: string[] } => { const [core, ...preParts] = v.trim().replace(/^v/, '').split('-'); const nums = core.split('.').map((n) => Number(n) || 0); diff --git a/src/utils/ci.ts b/src/utils/ci.ts index 196b2dc..c5a8153 100644 --- a/src/utils/ci.ts +++ b/src/utils/ci.ts @@ -40,3 +40,39 @@ export function isCI(): boolean { return !process.stdout.isTTY; } + +/** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */ +export interface CiContext { + /** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */ + provider: string | null; + /** The wrapper's own version, if it forwarded one (DCD_CI_WRAPPER_VERSION). */ + wrapperVersion: string | null; +} + +/** Infer the CI provider from the env vars each platform sets natively. */ +function inferProvider(): string | null { + const env = process.env; + if (env.GITHUB_ACTIONS) return 'github'; + if (env.BITRISE_IO || env.BITRISE_BUILD_NUMBER) return 'bitrise'; + if (env.BITBUCKET_BUILD_NUMBER) return 'bitbucket'; + if (env.EAS_BUILD || env.EAS_BUILD_RUNNER || env.EAS_BUILD_ID) return 'eas'; + if (env.GITLAB_CI) return 'gitlab'; + if (env.CIRCLECI) return 'circleci'; + return null; +} + +/** + * Resolve the CI integration context the CLI forwards to the notices API so + * notices can target a specific integration/version (e.g. "Bitbucket Pipe < + * 1.1.0"). The DCD CI wrappers set `DCD_CI_PROVIDER` / `DCD_CI_WRAPPER_VERSION` + * explicitly (preferred — carries the wrapper version); otherwise the provider + * is inferred from native env vars and the version is unknown. + */ +export function detectCiContext(): CiContext { + const forwardedProvider = process.env.DCD_CI_PROVIDER?.trim(); + const forwardedVersion = process.env.DCD_CI_WRAPPER_VERSION?.trim(); + return { + provider: forwardedProvider || inferProvider(), + wrapperVersion: forwardedVersion || null, + }; +} diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index a48324a..af1934d 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -1,4 +1,5 @@ import type { AuthContext } from '../types/domain/auth.types.js'; +import type { Notice } from '../services/notices.service.js'; export interface CompatibilityData { android: Record; @@ -9,20 +10,46 @@ export interface CompatibilityData { latestVersion: string; supportedVersions: string[]; }; + /** Active CLI notices, piggybacked onto the compatibility response by the API. */ + notices?: Notice[]; +} + +/** Identity the CLI forwards so the API can target notices by version / CI. */ +export interface ClientContext { + cliVersion?: string; + ciProvider?: string | null; + ciWrapperVersion?: string | null; } let cachedCompatibilityData: CompatibilityData | null = null; -export async function fetchCompatibilityData(apiUrl: string, auth: AuthContext): Promise { +export async function fetchCompatibilityData( + apiUrl: string, + auth: AuthContext, + clientContext?: ClientContext, +): Promise { if (cachedCompatibilityData) { return cachedCompatibilityData; } + // Forward CLI / CI identity so the API can version-filter and target notices. + const noticeHeaders: Record = {}; + if (clientContext?.cliVersion) { + noticeHeaders['x-dcd-cli-version'] = clientContext.cliVersion; + } + if (clientContext?.ciProvider) { + noticeHeaders['x-dcd-ci-provider'] = clientContext.ciProvider; + } + if (clientContext?.ciWrapperVersion) { + noticeHeaders['x-dcd-ci-wrapper-version'] = clientContext.ciWrapperVersion; + } + try { const response = await fetch(`${apiUrl}/results/compatibility/data`, { headers: { 'Content-Type': 'application/json', ...auth.headers, + ...noticeHeaders, }, method: 'GET', }); diff --git a/src/utils/styling.ts b/src/utils/styling.ts index 78a3625..53d1e7b 100644 --- a/src/utils/styling.ts +++ b/src/utils/styling.ts @@ -16,6 +16,7 @@ export const stripAnsi = (s: string): string => s.replace(/\u001B\[[0-9;]*m/g, ' */ export const symbols = { cancelled: chalk.gray('⊘'), + deprecation: chalk.red('⚠'), error: chalk.red('✗'), info: chalk.blue('ℹ'), pending: chalk.yellow('⏸'), diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 3977616..7b2b141 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -70,6 +70,11 @@ export const ui = { }); }, + /** `⚠ message` in red — a deprecation; reads as more serious than a (yellow) {@link warn}. */ + deprecation(message: string): string { + return `${symbols.deprecation} ${message}`; + }, + /** `ℹ message` — neutral, standalone information. */ info(message: string): string { return `${symbols.info} ${message}`; diff --git a/test/integration/cloud.integration.test.ts b/test/integration/cloud.integration.test.ts index e87f06f..253057f 100644 --- a/test/integration/cloud.integration.test.ts +++ b/test/integration/cloud.integration.test.ts @@ -208,7 +208,7 @@ appId: com.example.app }; it('should support custom Maestro versions', async () => { - const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --maestro-version 1.39.5 --name test-maestro-version --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --maestro-version 2.2.0 --name test-maestro-version --async`; const { stdout } = await exec(command, { timeout: 15_000 }); expectAsyncSubmission(stdout); diff --git a/test/integration/upload.integration.test.ts b/test/integration/upload.integration.test.ts index ceadc8a..b41dc8e 100644 --- a/test/integration/upload.integration.test.ts +++ b/test/integration/upload.integration.test.ts @@ -188,20 +188,14 @@ describe('Upload Command Integration Tests', () => { expect(stdout).to.include('skipping upload'); }); - it('should attempt a real upload when the SHA check is bypassed', async () => { + it('should perform a real upload when the SHA check is bypassed', async () => { const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ignore-sha-check --json`; - // Bypassing dedup makes the CLI upload to the storage URLs from the - // mock's example response, which point at real (unwritable) hosts — - // so this deterministically fails after attempting every upload path. - // It still verifies --ignore-sha-check skips the dedup short-circuit. - const { code, stdout } = await runExpectingFailure(command, { - timeout: 60_000, - }); - expect(code).to.equal(1); - const result = JSON.parse(stdout); - expect(result).to.have.property('status', 'FAILED'); - expect(result.error).to.include('All uploads failed'); + // --ignore-sha-check skips the dedup short-circuit and performs a real + // upload. The mock returns a valid `uploads/` staging path, so the TUS + // fallback upload succeeds and the command returns the new binary id. + const { stdout } = await exec(command, { timeout: 60_000 }); + expectUploadJson(stdout); }); }); });