From bd0ad455bbd203312a182cdb0f3a1873d3a0ab9c Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:08:32 +0100 Subject: [PATCH 1/9] =?UTF-8?q?feat(device):=20add=20the=20iPhone=2017/18?= =?UTF-8?q?=20Pro/Air=20and=20M5=20iPads,=20drop=20two=20pha=E2=80=A6=20(#?= =?UTF-8?q?187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(device): add the iPhone 17/18 Pro/Air and M5 iPads, drop two phantom slugs Mirrors the API enum after iOS 27 went GA. Also removes iphone-14-pro and iphone-15-pro, which this file has listed since it was written but which have never existed in the API — they only ever produced help text offering a value the backend rejects. Co-authored-by: Claude Opus 5 (1M context) --- src/types/domain/device.types.ts | 19 +- src/types/generated/schema.types.ts | 294 ++++++++++++---------------- 2 files changed, 143 insertions(+), 170 deletions(-) diff --git a/src/types/domain/device.types.ts b/src/types/domain/device.types.ts index ba2f0fa..6bbe915 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -1,18 +1,31 @@ /** * Device type definitions - should be kept in sync with API - * @see /Users/riglar/repos/dcd/api/src/common/types/device.types.ts + * @see dcd/api/src/common/types/device.types.ts + * + * These drive `--ios-device` / `--android-device` help text and nothing else: + * the actual validation runs against the live compatibility payload + * (device-validation.service.ts), so a value the API has dropped produces a + * clear "not supported" error rather than a wrong local rejection. That makes + * a CLI *ahead* of the API safe and a CLI *behind* it not. + * + * `iphone-14-pro` and `iphone-15-pro` were listed here but have never existed + * in the API enum; removed 2026-09-21 rather than carried forward. */ export enum EiOSDevices { 'ipad-pro-6th-gen' = 'ipad-pro-6th-gen', + 'ipad-pro-m5-11' = 'ipad-pro-m5-11', + 'ipad-pro-m5-13' = 'ipad-pro-m5-13', 'iphone-14' = 'iphone-14', - 'iphone-14-pro' = 'iphone-14-pro', 'iphone-15' = 'iphone-15', - 'iphone-15-pro' = 'iphone-15-pro', 'iphone-16' = 'iphone-16', 'iphone-16-plus' = 'iphone-16-plus', 'iphone-16-pro' = 'iphone-16-pro', 'iphone-16-pro-max' = 'iphone-16-pro-max', + 'iphone-17' = 'iphone-17', + 'iphone-18-pro' = 'iphone-18-pro', + 'iphone-18-pro-max' = 'iphone-18-pro-max', + 'iphone-air' = 'iphone-air', } export enum EAndroidDevices { diff --git a/src/types/generated/schema.types.ts b/src/types/generated/schema.types.ts index 29897ea..c59d2a6 100644 --- a/src/types/generated/schema.types.ts +++ b/src/types/generated/schema.types.ts @@ -1652,9 +1652,11 @@ export interface components { /** @enum {string} */ iOSVersion?: "17" | "18" | "26" | "27"; /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "iphone-17" | "iphone-air" | "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-6th-gen" | "ipad-pro-m5-11" | "ipad-pro-m5-13"; platform?: string; googlePlay?: boolean; + /** @description Cancel the still-queued tests of the previous run from the same CI context. The context is derived from the run metadata (gh_repo/bb_repo, gh_branch/gh_pr_number or their bb_ twins, and gh_check_name); without it nothing is cancelled. Tests already running are left to finish. */ + cancelPrevious?: boolean; config: string; name?: string; /** @enum {string} */ @@ -1692,9 +1694,11 @@ export interface components { /** @enum {string} */ iOSVersion?: "17" | "18" | "26" | "27"; /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "iphone-17" | "iphone-air" | "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-6th-gen" | "ipad-pro-m5-11" | "ipad-pro-m5-13"; platform?: string; googlePlay?: boolean; + /** @description Cancel the still-queued tests of the previous run from the same CI context. The context is derived from the run metadata (gh_repo/bb_repo, gh_branch/gh_pr_number or their bb_ twins, and gh_check_name); without it nothing is cancelled. Tests already running are left to finish. */ + cancelPrevious?: boolean; config: string; name?: string; /** @enum {string} */ @@ -1743,9 +1747,11 @@ export interface components { /** @enum {string} */ iOSVersion?: "17" | "18" | "26" | "27"; /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "iphone-17" | "iphone-air" | "iphone-18-pro" | "iphone-18-pro-max" | "ipad-pro-6th-gen" | "ipad-pro-m5-11" | "ipad-pro-m5-13"; platform?: string; googlePlay?: boolean; + /** @description Cancel the still-queued tests of the previous run from the same CI context. The context is derived from the run metadata (gh_repo/bb_repo, gh_branch/gh_pr_number or their bb_ twins, and gh_check_name); without it nothing is cancelled. Tests already running are left to finish. */ + cancelPrevious?: boolean; config: string; name?: string; /** @enum {string} */ @@ -1881,6 +1887,7 @@ export interface components { retry_of?: number; fail_reason?: string; duration_seconds?: number; + cancellation_reason?: string; simulator_name?: string; config?: Record; }; @@ -2191,6 +2198,8 @@ export interface operations { message?: string; success?: boolean; cancelledCount?: number; + /** @description Credits returned for tests that had not started yet. 0 when nothing was refundable. See the billing docs for the rate. */ + refundedAmount?: number; }; }; }; @@ -2952,174 +2961,125 @@ export interface operations { * "statusCode": 200, * "data": { * "ios": { - * "iphone-14": { - * "name": "iPhone 14", - * "versions": [ - * "17", - * "18" - * ], - * "deprecated": false - * }, - * "iphone-15": { - * "name": "iPhone 15", - * "versions": [ - * "17" - * ], - * "deprecated": false - * }, - * "iphone-16": { - * "name": "iPhone 16", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-plus": { - * "name": "iPhone 16 Plus", - * "versions": [ - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro": { - * "name": "iPhone 16 Pro", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro-max": { - * "name": "iPhone 16 Pro Max", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "ipad-pro-6th-gen": { - * "name": "iPad Pro (6th gen)", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * } + * "iphone-14": [ + * "17", + * "18" + * ], + * "iphone-15": [ + * "17" + * ], + * "iphone-16": [ + * "18", + * "26", + * "27" + * ], + * "iphone-16-plus": [ + * "26", + * "27" + * ], + * "iphone-16-pro": [ + * "18", + * "26", + * "27" + * ], + * "iphone-16-pro-max": [ + * "18", + * "26", + * "27" + * ], + * "iphone-17": [ + * "26", + * "27" + * ], + * "iphone-air": [ + * "26", + * "27" + * ], + * "iphone-18-pro": [ + * "27" + * ], + * "iphone-18-pro-max": [ + * "27" + * ], + * "ipad-pro-6th-gen": [ + * "18", + * "26", + * "27" + * ], + * "ipad-pro-m5-11": [ + * "26", + * "27" + * ], + * "ipad-pro-m5-13": [ + * "26", + * "27" + * ] * }, * "android": { - * "pixel-6": { - * "name": "Pixel 6", - * "apiLevels": [ - * "29", - * "30", - * "31", - * "32", - * "33", - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-6-pro": { - * "name": "Pixel 6 Pro", - * "apiLevels": [ - * "33", - * "35" - * ], - * "deprecated": false - * }, - * "pixel-7": { - * "name": "Pixel 7", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-7-pro": { - * "name": "Pixel 7 Pro", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-8": { - * "name": "Pixel 8", - * "apiLevels": [ - * "34", - * "35", - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10": { - * "name": "Pixel 10", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10-pro": { - * "name": "Pixel 10 Pro", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10-pro-xl": { - * "name": "Pixel 10 Pro XL", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-10-pro-fold": { - * "name": "Pixel 10 Pro Fold", - * "apiLevels": [ - * "36", - * "37" - * ], - * "deprecated": false - * }, - * "pixel-11": { - * "name": "Pixel 11", - * "apiLevels": [ - * "37" - * ], - * "deprecated": false - * }, - * "generic-tablet": { - * "name": "Generic Tablet", - * "apiLevels": [ - * "33", - * "36", - * "37" - * ], - * "deprecated": false - * } + * "pixel-6": [ + * "29", + * "30", + * "31", + * "32", + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-6-pro": [ + * "33", + * "35" + * ], + * "pixel-7": [ + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-7-pro": [ + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-8": [ + * "34", + * "35", + * "36", + * "37" + * ], + * "pixel-10": [ + * "36", + * "37" + * ], + * "pixel-10-pro": [ + * "36", + * "37" + * ], + * "pixel-10-pro-xl": [ + * "36", + * "37" + * ], + * "pixel-10-pro-fold": [ + * "36", + * "37" + * ], + * "pixel-11": [ + * "37" + * ], + * "generic-tablet": [ + * "33", + * "36", + * "37" + * ] * }, * "androidPlay": { - * "pixel-7": { - * "name": "Pixel 7 (Google Play)", - * "apiLevels": [ - * "34" - * ], - * "deprecated": false - * } + * "pixel-7": [ + * "34" + * ] * }, * "maestro": { * "supportedVersions": [ From 5f74ce5bf867804fba21eb24e12e31c00c519bd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:49:03 +0100 Subject: [PATCH 2/9] deps: bump the minor-and-patch group with 6 updates (#188) Bumps the minor-and-patch group with 6 updates: | Package | From | To | | --- | --- | --- | | [@clack/prompts](https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts) | `1.8.0` | `1.8.1` | | [js-yaml](https://github.com/nodeca/js-yaml) | `5.4.1` | `5.4.2` | | [zod](https://github.com/colinhacks/zod) | `4.6.2` | `4.6.5` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.5.1` | `26.6.1` | | [mocha](https://github.com/mochajs/mocha) | `12.0.1` | `12.0.2` | | [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` | Updates `@clack/prompts` from 1.8.0 to 1.8.1 - [Release notes](https://github.com/bombshell-dev/clack/releases) - [Changelog](https://github.com/bombshell-dev/clack/blob/main/packages/prompts/CHANGELOG.md) - [Commits](https://github.com/bombshell-dev/clack/commits/@clack/prompts@1.8.1/packages/prompts) Updates `js-yaml` from 5.4.1 to 5.4.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.4.1...5.4.2) Updates `zod` from 4.6.2 to 4.6.5 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.6.2...v4.6.5) Updates `@types/node` from 26.5.1 to 26.6.1 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `mocha` from 12.0.1 to 12.0.2 - [Release notes](https://github.com/mochajs/mocha/releases) - [Changelog](https://github.com/mochajs/mocha/blob/main/CHANGELOG.md) - [Commits](https://github.com/mochajs/mocha/compare/v12.0.1...v12.0.2) Updates `prettier` from 3.9.6 to 3.9.8 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8) --- updated-dependencies: - dependency-name: "@clack/prompts" dependency-version: 1.8.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: js-yaml dependency-version: 5.4.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: zod dependency-version: 4.6.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@types/node" dependency-version: 26.6.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: mocha dependency-version: 12.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: prettier dependency-version: 3.9.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 119 +++++++++++++++---------------------------------- 1 file changed, 37 insertions(+), 82 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02679d2..7431d32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,10 +41,10 @@ importers: dependencies: '@clack/prompts': specifier: ^1.6.0 - version: 1.8.0 + version: 1.8.1 '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.30.0(zod@4.6.2) + version: 1.30.0(zod@4.6.5) '@supabase/supabase-js': specifier: ^2.108.2 version: 2.116.0 @@ -59,7 +59,7 @@ importers: version: 0.2.2 js-yaml: specifier: ^5.2.2 - version: 5.4.1 + version: 5.4.2 node-apk: specifier: ^1.2.1 version: 1.2.1 @@ -80,7 +80,7 @@ importers: version: 3.3.1 zod: specifier: ^4.4.3 - version: 4.6.2 + version: 4.6.5 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -96,7 +96,7 @@ importers: version: 10.0.10 '@types/node': specifier: ^26.0.0 - version: 26.5.1 + version: 26.6.1 '@types/yazl': specifier: ^3.3.1 version: 3.3.1 @@ -117,10 +117,10 @@ importers: version: 9.1.7 mocha: specifier: ^12.0.0 - version: 12.0.1 + version: 12.0.2 prettier: specifier: ^3.8.4 - version: 3.9.6 + version: 3.9.8 shx: specifier: ^0.4.0 version: 0.4.0 @@ -142,12 +142,12 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - '@clack/core@1.5.0': - resolution: {integrity: sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==} + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.8.0': - resolution: {integrity: sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==} + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} engines: {node: '>= 20.12.0'} '@esbuild/aix-ppc64@0.28.2': @@ -463,8 +463,8 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.5.1': - resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} + '@types/node@26.6.1': + resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==} '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} @@ -910,10 +910,6 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - find-up@8.0.0: - resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} - engines: {node: '>=20'} - flat-cache@6.1.23: resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} @@ -1105,8 +1101,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@5.4.1: - resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true jsesc@3.1.0: @@ -1137,10 +1133,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - locate-path@8.0.0: - resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} - engines: {node: '>=20'} - lodash._baseiteratee@4.7.0: resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} @@ -1223,8 +1215,8 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mocha@12.0.1: - resolution: {integrity: sha512-/ILgtFHV+R7EJLwN0OkrPPOu6wbJyrtuRtTJ5szdFHv2A3mPmkbu3r1H2q6FhaAfywDJCulcQQ2S9qDGfZW6dw==} + mocha@12.0.2: + resolution: {integrity: sha512-SjAulGHxlMJLCD1bhvJBAiYwYxglydRzA9PDQ5MALq1oLRMMtIhxSQJ0olCFgA7c9YbTdxC0KOV0wl8JEDYVhA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1291,18 +1283,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-locate@6.0.0: - resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-timeout@6.1.4: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} @@ -1360,8 +1344,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + prettier@3.9.8: + resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} engines: {node: '>=14'} hasBin: true @@ -1620,10 +1604,6 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1686,17 +1666,13 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: zod: ^3.25.28 || ^4 - zod@4.6.2: - resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} snapshots: @@ -1712,14 +1688,14 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@clack/core@1.5.0': + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.8.0': + '@clack/prompts@1.8.1': dependencies: - '@clack/core': 1.5.0 + '@clack/core': 1.5.1 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 @@ -1873,7 +1849,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@modelcontextprotocol/sdk@1.30.0(zod@4.6.2)': + '@modelcontextprotocol/sdk@1.30.0(zod@4.6.5)': dependencies: '@hono/node-server': 2.0.12(hono@4.13.5) ajv: 8.20.0 @@ -1890,8 +1866,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.6.2 - zod-to-json-schema: 3.25.2(zod@4.6.2) + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -1956,13 +1932,13 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/node@26.5.1': + '@types/node@26.6.1': dependencies: undici-types: 8.9.0 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.1 '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0)(typescript@6.0.3))(eslint@10.10.0)(typescript@6.0.3)': dependencies: @@ -2500,11 +2476,6 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - find-up@8.0.0: - dependencies: - locate-path: 8.0.0 - unicorn-magic: 0.3.0 - flat-cache@6.1.23: dependencies: cacheable: 2.5.0 @@ -2657,7 +2628,7 @@ snapshots: js-base64@3.7.8: {} - js-yaml@5.4.1: + js-yaml@5.4.2: dependencies: argparse: 2.0.1 @@ -2684,10 +2655,6 @@ snapshots: dependencies: p-locate: 5.0.0 - locate-path@8.0.0: - dependencies: - p-locate: 6.0.0 - lodash._baseiteratee@4.7.0: dependencies: lodash._stringtopath: 4.8.0 @@ -2759,17 +2726,17 @@ snapshots: dependencies: minipass: 7.1.3 - mocha@12.0.1: + mocha@12.0.2: dependencies: browser-stdout: 1.3.1 chokidar: 5.0.0 debug: 4.4.3(supports-color@8.1.1) diff: 8.0.3 - find-up: 8.0.0 + find-up-simple: 1.0.1 glob: 13.0.6 is-path-inside: 4.0.0 is-unicode-supported: 0.1.0 - js-yaml: 5.4.1 + js-yaml: 5.4.2 minimatch: 10.2.3 ms: 2.1.3 picocolors: 1.1.1 @@ -2831,18 +2798,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 - p-limit@4.0.0: - dependencies: - yocto-queue: 1.2.2 - p-locate@5.0.0: dependencies: p-limit: 3.1.0 - p-locate@6.0.0: - dependencies: - p-limit: 4.0.0 - p-timeout@6.1.4: {} parseurl@1.3.3: {} @@ -2879,7 +2838,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.6: {} + prettier@3.9.8: {} proper-lockfile@4.1.2: dependencies: @@ -3149,8 +3108,6 @@ snapshots: undici-types@8.9.0: {} - unicorn-magic@0.3.0: {} - unpipe@1.0.0: {} update-browserslist-db@1.3.2(browserslist@4.28.9): @@ -3198,10 +3155,8 @@ snapshots: yocto-queue@0.1.0: {} - yocto-queue@1.2.2: {} - - zod-to-json-schema@3.25.2(zod@4.6.2): + zod-to-json-schema@3.25.2(zod@4.6.5): dependencies: - zod: 4.6.2 + zod: 4.6.5 - zod@4.6.2: {} + zod@4.6.5: {} From bbf0b8aefcc712d79439969e9221a76fcb839bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:53:34 +0100 Subject: [PATCH 3/9] chore: bump eslint-plugin-unicorn from 74.0.0 to 76.0.0 (#189) Bumps [eslint-plugin-unicorn](https://github.com/sindresorhus/eslint-plugin-unicorn) from 74.0.0 to 76.0.0. - [Release notes](https://github.com/sindresorhus/eslint-plugin-unicorn/releases) - [Commits](https://github.com/sindresorhus/eslint-plugin-unicorn/compare/v74.0.0...v76.0.0) --- updated-dependencies: - dependency-name: eslint-plugin-unicorn dependency-version: 75.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 178 +++++++++++++++---------------------------------- 2 files changed, 53 insertions(+), 127 deletions(-) diff --git a/package.json b/package.json index ea0c840..83f801e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "chai": "^6.2.2", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-unicorn": "^74.0.0", + "eslint-plugin-unicorn": "^76.0.0", "husky": "^9.1.7", "mocha": "^12.0.0", "prettier": "^3.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7431d32..46ec460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,8 +110,8 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.10.0) eslint-plugin-unicorn: - specifier: ^74.0.0 - version: 74.0.0(eslint@10.10.0) + specifier: ^76.0.0 + version: 76.0.0(eslint@10.10.0) husky: specifier: ^9.1.7 version: 9.1.7 @@ -328,8 +328,8 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@4.1.0': - resolution: {integrity: sha512-cg0ohyrAG3swyGqt8t1K/OK97DqBw/ftDvlvyY1fmEst5B40UOmsimwLENq74z2dyw5CDM+3zJIW+CV2nFNDdA==} + '@eslint/css-tree@4.1.1': + resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -571,8 +571,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.21: - resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true @@ -595,8 +595,8 @@ packages: browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - browserslist@4.28.9: - resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -607,8 +607,8 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - builtin-modules@5.3.0: - resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} + builtin-modules@5.4.0: + resolution: {integrity: sha512-JCWSCdun+4Ovd9BhM/Vtlmwb7oD6Wl4YiPRXXwhAuwlPhW1un/MKgXn7GZoFm53ginnoNzus69V8JWx0K393jg==} engines: {node: '>=18.20'} bytes@3.1.2: @@ -670,10 +670,6 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} - convert-hrtime@5.0.0: - resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} - engines: {node: '>=12'} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -732,8 +728,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.422: - resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + electron-to-chromium@1.5.433: + resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -742,8 +738,8 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} es-define-property@1.0.1: @@ -780,8 +776,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-unicorn@74.0.0: - resolution: {integrity: sha512-AGnsGi2SxHg1HEAXxn9nSnZfyjvTWkxm8E8hpd/9tD6dLjBUdcD7+D6ZN64HmmCXTSXlrwVyUqe20Uyb2CaurA==} + eslint-plugin-unicorn@76.0.0: + resolution: {integrity: sha512-3ywJrCMHKYhcBP5yFzGaOOX3dY7+iR3hvpuoS/0EnhRFgVWLejly+t4fOdJvWQxJdHZl1C+QBW7hybXPDld4Pw==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -932,10 +928,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function-timeout@1.0.2: - resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1065,10 +1057,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-identifier@1.1.0: - resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} - engines: {node: '>=18'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1161,16 +1149,12 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} - make-asynchronous@1.1.0: - resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} - engines: {node: '>=18'} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdn-data@2.34.0: - resolution: {integrity: sha512-OgIlLv0NxJKVW4GTSAoEgpRGd4F2XCqGinK0MsMlBCCS/Zcm2/LsbercNWNA7PeMMcjl75NnI97eqyo7zkdxWA==} + mdn-data@2.35.0: + resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} @@ -1240,8 +1224,8 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} - node-releases@2.0.54: - resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} engines: {node: '>=18'} node-stream-zip@1.16.0: @@ -1271,10 +1255,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - p-event@6.0.1: - resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} - engines: {node: '>=16.17'} - p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -1287,10 +1267,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1397,8 +1373,8 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - regjsparser@0.13.2: - resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + regjsparser@0.13.3: + resolution: {integrity: sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==} hasBin: true require-from-string@2.0.2: @@ -1527,10 +1503,6 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - super-regex@1.1.0: - resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} - engines: {node: '>=18'} - supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -1543,10 +1515,6 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - time-span@5.1.0: - resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} - engines: {node: '>=12'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -1581,10 +1549,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -1608,8 +1572,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.3.2: - resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -1624,9 +1588,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - web-worker@1.5.0: - resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} - which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -1654,8 +1615,8 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true @@ -1801,9 +1762,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@4.1.0': + '@eslint/css-tree@4.1.1': dependencies: - mdn-data: 2.34.0 + mdn-data: 2.35.0 source-map-js: 1.2.1 '@eslint/js@10.0.1(eslint@10.10.0)': @@ -2068,7 +2029,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.21: {} + baseline-browser-mapping@2.11.25: {} body-parser@2.3.0: dependencies: @@ -2096,19 +2057,19 @@ snapshots: browser-stdout@1.3.1: {} - browserslist@4.28.9: + browserslist@4.29.0: dependencies: - baseline-browser-mapping: 2.11.21 + baseline-browser-mapping: 2.11.25 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.422 - node-releases: 2.0.54 - update-browserslist-db: 1.3.2(browserslist@4.28.9) + electron-to-chromium: 1.5.433 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) buffer-crc32@1.0.0: {} buffer-from@1.1.2: {} - builtin-modules@5.3.0: {} + builtin-modules@5.4.0: {} bytes@3.1.2: {} @@ -2159,15 +2120,13 @@ snapshots: content-type@2.0.0: {} - convert-hrtime@5.0.0: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} core-js-compat@3.50.0: dependencies: - browserslist: 4.28.9 + browserslist: 4.29.0 cors@2.8.6: dependencies: @@ -2212,7 +2171,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.422: {} + electron-to-chromium@1.5.433: {} encodeurl@2.0.0: {} @@ -2220,7 +2179,7 @@ snapshots: dependencies: once: 1.4.0 - entities@8.0.0: {} + entities@8.1.0: {} es-define-property@1.0.1: {} @@ -2269,29 +2228,29 @@ snapshots: dependencies: eslint: 10.10.0 - eslint-plugin-unicorn@74.0.0(eslint@10.10.0): + eslint-plugin-unicorn@76.0.0(eslint@10.10.0): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) - '@eslint/css-tree': 4.1.0 - browserslist: 4.28.9 + '@eslint/css-tree': 4.1.1 + browserslist: 4.29.0 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.50.0 detect-indent: 7.0.2 - entities: 8.0.0 + entities: 8.1.0 eslint: 10.10.0 find-up-simple: 1.0.1 globals: 17.12.0 + identifier-regex: 1.1.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 - is-identifier: 1.1.0 pluralize: 8.0.0 quote-js-string: 0.1.0 - regjsparser: 0.13.2 + regjsparser: 0.13.3 reserved-identifiers: 1.2.0 semver: 7.8.5 strip-indent: 4.1.1 - yaml: 2.9.0 + yaml: 2.9.1 eslint-scope@9.1.2: dependencies: @@ -2493,8 +2452,6 @@ snapshots: function-bind@1.1.2: {} - function-timeout@1.0.2: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2593,7 +2550,7 @@ snapshots: is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.3.0 + builtin-modules: 5.4.0 is-core-module@2.16.2: dependencies: @@ -2605,11 +2562,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-identifier@1.1.0: - dependencies: - identifier-regex: 1.1.0 - super-regex: 1.1.0 - is-number@7.0.0: {} is-path-inside@4.0.0: {} @@ -2683,15 +2635,9 @@ snapshots: lru-cache@11.5.2: {} - make-asynchronous@1.1.0: - dependencies: - p-event: 6.0.1 - type-fest: 4.41.0 - web-worker: 1.5.0 - math-intrinsics@1.1.0: {} - mdn-data@2.34.0: {} + mdn-data@2.35.0: {} media-typer@1.1.1: {} @@ -2759,7 +2705,7 @@ snapshots: node-forge@1.4.0: {} - node-releases@2.0.54: {} + node-releases@2.0.56: {} node-stream-zip@1.16.0: {} @@ -2788,10 +2734,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - p-event@6.0.1: - dependencies: - p-timeout: 6.1.4 - p-finally@1.0.0: {} p-limit@3.1.0: @@ -2802,8 +2744,6 @@ snapshots: dependencies: p-limit: 3.1.0 - p-timeout@6.1.4: {} - parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -2888,7 +2828,7 @@ snapshots: dependencies: resolve: 1.22.12 - regjsparser@0.13.2: + regjsparser@0.13.3: dependencies: jsesc: 3.1.0 @@ -3024,12 +2964,6 @@ snapshots: strip-json-comments@5.0.3: {} - super-regex@1.1.0: - dependencies: - function-timeout: 1.0.2 - make-asynchronous: 1.1.0 - time-span: 5.1.0 - supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -3044,10 +2978,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - time-span@5.1.0: - dependencies: - convert-hrtime: 5.0.0 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -3085,8 +3015,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@4.41.0: {} - type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -3110,9 +3038,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.3.2(browserslist@4.28.9): + update-browserslist-db@1.3.3(browserslist@4.29.0): dependencies: - browserslist: 4.28.9 + browserslist: 4.29.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -3127,8 +3055,6 @@ snapshots: vary@1.1.2: {} - web-worker@1.5.0: {} - which@1.3.1: dependencies: isexe: 2.0.0 @@ -3147,7 +3073,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.9.0: {} + yaml@2.9.1: {} yazl@3.3.1: dependencies: From f2692717086768548ff12fc91ec05923edb46eec Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:02:18 +0100 Subject: [PATCH 4/9] fix(upload): honour DCD_ENCRYPT like dcd cloud does (#195) dcd upload passed Boolean(args.encrypt) to the binary upload, so without --encrypt it sent an explicit false, and an explicit value beats the environment in isEncryptionEnabled. DCD_ENCRYPT=1 (and the legacy DCD_ENCRYPT_BINARIES=1) was therefore ignored and the binary went up in plaintext, although --encrypt's help says the variable enables it. It now uses the same rule as dcd cloud: --encrypt turns encryption on, and otherwise the environment decides. --- src/commands/upload.ts | 6 +- test/unit/upload-encrypt.test.ts | 106 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 test/unit/upload-encrypt.test.ts diff --git a/src/commands/upload.ts b/src/commands/upload.ts index f56cb83..5fd6f34 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -8,6 +8,7 @@ import { uploadBinary, verifyAppZip } from '../methods.js'; import { resolveAuth } from '../utils/auth.js'; import { CliError, logger } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; +import { isEncryptionEnabled } from '../utils/envelope.js'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js'; import { colors, formatId } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -43,7 +44,10 @@ export const uploadCommand = defineCommand({ const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); - const encryptBinary = Boolean(args['encrypt']); + // Same rule as `dcd cloud`: the flag turns encryption on, and without it + // DCD_ENCRYPT decides. Passing `false` here used to mean "explicitly + // off", which silently beat DCD_ENCRYPT=1 for every `dcd upload`. + const encryptBinary = isEncryptionEnabled(args['encrypt'] ? true : undefined); const debug = Boolean(args.debug); const positional = args.appFile as string | undefined; diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts new file mode 100644 index 0000000..ce558ac --- /dev/null +++ b/test/unit/upload-encrypt.test.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import { generateKeyPairSync } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { uploadCommand } from '../../src/commands/upload.js'; + +/** + * `dcd upload` must honour DCD_ENCRYPT=1 exactly as `dcd cloud` does. It used + * to pass `Boolean(args.encrypt)`, and an explicit `false` beats the env var, + * so an exported DCD_ENCRYPT=1 was silently ignored and the binary went up in + * plaintext. + * + * Observed through the dedup lookup, which is the first request either way: + * an encrypted upload deduplicates on the plaintext hash and says so + * (`{ shaPlain, encrypted: true }`), a plaintext one sends `{ sha }`. + */ +const API = 'http://localhost:9999'; +const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); + +describe('dcd upload encryption', () => { + const ORIGINAL_ENV = { ...process.env }; + const realFetch = globalThis.fetch; + const realExit = process.exit; + let lookups: Array>; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-upload-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + process.env.DEVICE_CLOUD_API_KEY = 'test-key'; + delete process.env.DCD_ENCRYPT; + delete process.env.DCD_ENCRYPT_BINARIES; + + // A throwaway KEK so encryption can run without a pinned key. + const { publicKey } = generateKeyPairSync('x25519'); + const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${raw.toString('base64')}`; + + // Answer the dedup lookup with a hit of the matching kind, so the command + // finishes without uploading anything. + lookups = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + lookups.push(body); + return new Response( + JSON.stringify({ + appBinaryId: body.encrypted ? 'encrypted-binary' : 'plain-binary', + encrypted: Boolean(body.encrypted), + exists: true, + }), + { headers: { 'content-type': 'application/json' }, status: 200 }, + ); + }) as typeof fetch; + + // A failure must fail the test, not end the mocha process. + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + process.exit = realExit; + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + /** Run `dcd upload --json ` and return the binary id it printed. */ + async function upload(args: Record = {}): Promise { + let out = ''; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + await ( + uploadCommand.run as (ctx: { args: Record }) => Promise + )({ args: { appFile: APK, 'api-url': API, json: true, ...args } }); + } finally { + process.stdout.write = original; + } + + return (JSON.parse(out) as { appBinaryId: string }).appBinaryId; + } + + it('encrypts when DCD_ENCRYPT=1 is exported and --encrypt is not passed', async () => { + process.env.DCD_ENCRYPT = '1'; + + expect(await upload()).to.equal('encrypted-binary'); + expect(lookups[0]).to.include({ encrypted: true }); + expect(lookups[0]).to.have.property('shaPlain'); + }); + + it('still encrypts with --encrypt alone', async () => { + expect(await upload({ encrypt: true })).to.equal('encrypted-binary'); + }); + + it('uploads in plaintext when neither is set', async () => { + expect(await upload()).to.equal('plain-binary'); + expect(lookups[0]).to.have.property('sha'); + expect(lookups[0]).to.not.have.property('encrypted'); + }); +}); From a73689b1243c1060004b66a04b8dcbf775208416 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:03:01 +0100 Subject: [PATCH 5/9] fix(cloud): default HTML reports to report.zip; fix animations and help (#193) The html and html-detailed reports download as a ZIP (report.html plus its screenshots and assets), but without --html-path they were saved as ./report.html, a file no browser can open. They now default to ./report.zip in dcd cloud, dcd artifacts and the MCP download tool. An explicit --html-path is used exactly as given, and the Allure and JUnit defaults are unchanged. config.yaml's platform.ios / platform.android disableAnimations was picked by guessing the platform from --ios-device / --ios-version, so an iOS run without them read the android block. The app binary now decides when the CLI has it (.apk is Android; .app, .zip and Expo .tar.gz are iOS), with the old guess kept for --app-binary-id. The flag was also combined as flag || config, so it could only ever turn animations off: it now has no default, and an explicit --disable-animations or --no-disable-animations wins over config.yaml. Help text: --android-no-snapshot cold-boots automatically from API 34, not 35, and --cancel-previous needs repo plus branch or PR metadata rather than "CI"; without it nothing is superseded. --- src/commands/artifacts.ts | 2 +- src/commands/cloud.ts | 6 +- src/config/flags/device.flags.ts | 9 +- src/config/flags/execution.flags.ts | 2 +- src/config/flags/output.flags.ts | 2 +- src/gateways/api-gateway.ts | 3 +- src/mcp/tools/download-artifacts.ts | 19 ++-- src/mcp/tools/run-cloud-test.ts | 3 + src/services/notices.service.ts | 2 + src/services/report-download.service.ts | 4 +- src/services/test-submission.service.ts | 28 ++++- test/unit/disable-animations.test.ts | 130 ++++++++++++++++++++++ test/unit/report-download.service.test.ts | 64 +++++++++++ 13 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 test/unit/disable-animations.test.ts diff --git a/src/commands/artifacts.ts b/src/commands/artifacts.ts index aa5227a..ee6f75b 100644 --- a/src/commands/artifacts.ts +++ b/src/commands/artifacts.ts @@ -49,7 +49,7 @@ export const artifactsCommand = defineCommand({ 'html-path': { type: 'string', description: - 'Custom file path for downloaded HTML report (default: ./report.html)', + 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (default: ./report.zip)', }, 'junit-path': { type: 'string', diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index deac9d9..2800157 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -266,7 +266,9 @@ export const cloudCommand = defineCommand({ ); const showCrosshairs = Boolean(args['show-crosshairs']); const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']); - const disableAnimations = Boolean(args['disable-animations']); + // Left undefined when not passed, so config.yaml's per-platform value + // applies; --no-disable-animations is an explicit false that beats it. + const disableAnimations = args['disable-animations'] as boolean | undefined; const ghBranch = args.branch as string | undefined; const ghCommitSha = args['commit-sha'] as string | undefined; const ghRepoName = args['repo-name'] as string | undefined; @@ -838,6 +840,8 @@ export const cloudCommand = defineCommand({ androidNoSnapshot, apiUrl, appBinaryId: finalBinaryId, + // With --app-binary-id the binary's platform is unknown here. + appPlatform: appBinaryId ? undefined : platformFromAppFile(finalAppFile), cancelPrevious, cliVersion, commonRoot, diff --git a/src/config/flags/device.flags.ts b/src/config/flags/device.flags.ts index 4b9e6d9..eb0c49c 100644 --- a/src/config/flags/device.flags.ts +++ b/src/config/flags/device.flags.ts @@ -76,12 +76,15 @@ export const deviceFlags = { type: 'boolean', default: false, description: - '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 35+ but can be used to force cold boot on older API levels.', + '[Android only] Force cold boot instead of using snapshot boot. This is automatically enabled for API 34+ but can be used to force cold boot on older API levels.', }, + // No default: an unset flag must be distinguishable from --no-disable-animations + // so the platform's disableAnimations in config.yaml applies only when unset. 'disable-animations': { type: 'boolean', - default: false, description: - 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability.', + 'Disable device animations during test execution. On Android, disables system animation scales. On iOS, enables Reduce Motion. Reduces CPU load and may improve test reliability. Overrides platform.ios/android.disableAnimations in config.yaml.', + negativeDescription: + 'Keep device animations on, even where config.yaml sets platform.ios/android.disableAnimations', }, } as const satisfies ArgsDef; diff --git a/src/config/flags/execution.flags.ts b/src/config/flags/execution.flags.ts index 3e4988b..bc969af 100644 --- a/src/config/flags/execution.flags.ts +++ b/src/config/flags/execution.flags.ts @@ -8,7 +8,7 @@ export const executionFlags = { type: 'boolean', default: false, description: - 'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.', + 'Cancel the still-queued tests of the previous run from the same CI context: the same repo and branch or PR (and check name, if one is set), read from the run metadata. Needs repo plus branch/PR metadata (--repo-name with --branch or --pr-number, or --metadata gh_repo=… with gh_branch=… or gh_pr_number=…); without it nothing is superseded. Tests already running are left to finish; cancelled tests are refunded at 75%.', }, config: { type: 'string', diff --git a/src/config/flags/output.flags.ts b/src/config/flags/output.flags.ts index a5fcffc..304f5b4 100644 --- a/src/config/flags/output.flags.ts +++ b/src/config/flags/output.flags.ts @@ -22,7 +22,7 @@ export const outputFlags = { 'html-path': { type: 'string', description: - 'Custom file path for downloaded HTML report (requires --report html, default: ./report.html)', + 'Custom file path for the downloaded HTML report, a ZIP of report.html and its assets (requires --report html or html-detailed, default: ./report.zip)', }, async: { type: 'boolean', diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 2c3dfd9..37fd6da 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -785,7 +785,8 @@ export const ApiGateway = { }, html: { endpoint: `/results/${uploadId}/html-report`, - defaultFilename: `report-${uploadId}.html`, + // A ZIP of report.html plus its assets, not a bare HTML file. + defaultFilename: `report-${uploadId}.zip`, notFoundMessage: `Upload ID '${uploadId}' not found or no HTML report available for this upload`, errorPrefix: 'Failed to download HTML report', }, diff --git a/src/mcp/tools/download-artifacts.ts b/src/mcp/tools/download-artifacts.ts index 9e28175..5af9b97 100644 --- a/src/mcp/tools/download-artifacts.ts +++ b/src/mcp/tools/download-artifacts.ts @@ -41,7 +41,9 @@ export function registerDownloadArtifacts(server: McpServer): void { reportPath: z .string() .optional() - .describe('Local path for the report file (defaults depend on report type)'), + .describe( + 'Local path for the report file (default ./report.xml for junit, ./report.html for allure, ./report.zip for html, which is a ZIP of report.html and its assets)', + ), }, annotations: { readOnlyHint: true, openWorldHint: true }, }, @@ -68,13 +70,14 @@ export function registerDownloadArtifacts(server: McpServer): void { let reportPath: string | undefined; if (args.report) { - reportPath = args.reportPath - ? path.resolve(args.reportPath) - : path.resolve( - args.report === 'junit' - ? 'report.xml' - : 'report.html', - ); + // The HTML report is a ZIP of report.html plus assets; Allure's is + // a single HTML file. + const defaultReportPath = { + allure: 'report.html', + html: 'report.zip', + junit: 'report.xml', + }[args.report]; + reportPath = path.resolve(args.reportPath || defaultReportPath); await service.downloadReports({ apiUrl, auth, diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index a94d07e..231b7b9 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -7,6 +7,7 @@ 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 { platformFromAppFile } from '../../services/notices.service.js'; import { TestSubmissionService } from '../../services/test-submission.service.js'; import { VersionService } from '../../services/version.service.js'; import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js'; @@ -210,6 +211,8 @@ export function registerRunCloudTest(server: McpServer): void { const { buffer, fields } = await testSubmissionService.buildTestPayload({ apiUrl, appBinaryId, + // Picks the workspace config's per-platform disableAnimations. + appPlatform: args.appBinaryId ? undefined : platformFromAppFile(args.appFile), cliVersion, commonRoot, continueOnFailure, diff --git a/src/services/notices.service.ts b/src/services/notices.service.ts index ff5022f..4319c50 100644 --- a/src/services/notices.service.ts +++ b/src/services/notices.service.ts @@ -140,6 +140,8 @@ export function platformFromAppFile( appFile: string | undefined, ): 'android' | 'ios' | undefined { if (!appFile) return undefined; + // Expo iOS simulator builds (`dcd cloud` extracts these to a .app first). + if (appFile.split('?')[0].toLowerCase().endsWith('.tar.gz')) return 'ios'; const ext = appFile.split('?')[0].toLowerCase().match(/.([a-z0-9]+)$/)?.[1]; if (ext === 'apk' || ext === 'aab') return 'android'; if (ext === 'zip' || ext === 'app' || ext === 'ipa') return 'ios'; diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index 80437e5..fee0d3a 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -118,7 +118,9 @@ export class ReportDownloadService { case 'html': case 'html-detailed': { - const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.html'); + // The HTML report is a ZIP (report.html plus its screenshots and + // assets), so the default name says so. An explicit path is kept as-is. + const htmlReportPath = path.resolve(process.cwd(), htmlPath || 'report.zip'); await this.downloadReport('html', htmlReportPath, { ...downloadOptions, warnLogger, diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 5e79036..ede96e9 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -18,6 +18,12 @@ export interface TestSubmissionConfig { androidNoSnapshot?: boolean; apiUrl?: string; appBinaryId: string; + /** + * Platform of the app binary, when the CLI can tell from a local file + * (.apk → android; .app / .zip / Expo .tar.gz → ios). Unknown for + * --app-binary-id, which falls back to the device flags. + */ + appPlatform?: 'android' | 'ios'; /** * Ask the API to cancel the still-queued tests of the previous run from * the same CI context. Sent as its own field rather than inside `config`, @@ -31,6 +37,11 @@ export interface TestSubmissionConfig { debug?: boolean; deviceLocale?: string; deviceMatrix?: DeviceMatrixConfig[]; + /** + * --disable-animations as given on the command line: true / false when the + * flag (or --no-disable-animations) was passed, undefined when it wasn't — + * only then does the workspace config's per-platform value apply. + */ disableAnimations?: boolean; /** * Encrypt the flow zip and env vars before upload (#1151/#1152), each with its @@ -86,6 +97,7 @@ export class TestSubmissionService { const { apiUrl, appBinaryId, + appPlatform, encrypt = false, flowFile, executionPlan, @@ -278,8 +290,10 @@ export class TestSubmissionService { } // Platform used only to pick which workspace-config disableAnimations flag - // applies. A device matrix is single-platform; its first cell decides. Fall - // back to the scalar iOS flags for single-device submissions. + // applies. The binary decides when the CLI knows it — otherwise an iOS run + // without --ios-device/--ios-version read the android block. For an + // --app-binary-id, a device matrix (single-platform; its first cell + // decides) or the scalar iOS flags are the best evidence left. const matrixPlatform = deviceMatrix && deviceMatrix.length > 0 ? 'iOSDevice' in deviceMatrix[0] @@ -287,12 +301,18 @@ export class TestSubmissionService { : 'android' : undefined; const targetPlatform = - matrixPlatform ?? (iOSDevice || iOSVersion ? 'ios' : 'android'); + appPlatform ?? + matrixPlatform ?? + (iOSDevice || iOSVersion ? 'ios' : 'android'); const configYamlDisableAnimations = targetPlatform === 'ios' ? Boolean(workspaceConfig?.platform?.ios?.disableAnimations) : Boolean(workspaceConfig?.platform?.android?.disableAnimations); - const effectiveDisableAnimations = disableAnimations || configYamlDisableAnimations; + // An explicit --disable-animations / --no-disable-animations wins over + // config.yaml; previously `flag || config` meant the flag could only ever + // turn animations off, never keep them on against the config. + const effectiveDisableAnimations = + disableAnimations ?? configYamlDisableAnimations; const configPayload: Record< string, diff --git a/test/unit/disable-animations.test.ts b/test/unit/disable-animations.test.ts new file mode 100644 index 0000000..7aa07e3 --- /dev/null +++ b/test/unit/disable-animations.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { parseArgs } from 'citty'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { deviceFlags } from '../../src/config/flags/device.flags.js'; +import type { IExecutionPlan } from '../../src/services/execution-plan.service.js'; +import { platformFromAppFile } from '../../src/services/notices.service.js'; +import { + type TestSubmissionConfig, + TestSubmissionService, +} from '../../src/services/test-submission.service.js'; + +/** + * config.yaml can set `platform.ios.disableAnimations` and + * `platform.android.disableAnimations` separately. Which one applies must + * follow the app actually being tested, and an explicit + * --disable-animations / --no-disable-animations must beat either. + */ +describe('disableAnimations', () => { + let workspace: string; + let flowFile: string; + + before(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-animations-')); + flowFile = path.join(workspace, 'flow.yaml'); + fs.writeFileSync(flowFile, 'appId: com.example\n---\n- launchApp\n'); + }); + + after(() => { + fs.rmSync(workspace, { force: true, recursive: true }); + }); + + /** The disableAnimations value the payload's config carries. */ + async function sent( + overrides: Partial, + platform = { android: { disableAnimations: false }, ios: { disableAnimations: true } }, + ): Promise { + const executionPlan: IExecutionPlan = { + flowMetadata: {}, + flowOverrides: {}, + flowsToRun: [flowFile], + includedFiles: [], + referencedFiles: [], + totalFlowFiles: 1, + workspaceConfig: { platform }, + }; + const { fields } = await new TestSubmissionService().buildTestPayload({ + appBinaryId: 'binary-1', + cliVersion: '0.0.0-test', + commonRoot: workspace, + executionPlan, + flowFile, + maestroVersion: '2.10.0', + ...overrides, + }); + return (JSON.parse(fields.config) as { disableAnimations?: unknown }) + .disableAnimations; + } + + describe('which platform block of config.yaml applies', () => { + it('follows an iOS binary even without --ios-device / --ios-version', async () => { + // Used to read the android block here, because only the iOS flags + // marked a run as iOS. + expect(await sent({ appPlatform: 'ios' })).to.equal(true); + }); + + it('follows an Android binary even when an iOS flag is present', async () => { + expect(await sent({ appPlatform: 'android', iOSVersion: '18' })).to.equal(false); + }); + + it('falls back to the iOS flags when the binary is unknown (--app-binary-id)', async () => { + expect(await sent({ iOSDevice: 'iphone-16' })).to.equal(true); + expect(await sent({})).to.equal(false); + }); + + it('falls back to a device matrix when the binary is unknown', async () => { + expect( + await sent({ deviceMatrix: [{ iOSDevice: 'iphone-16', iOSVersion: '18' }] }), + ).to.equal(true); + }); + }); + + describe('an explicit flag', () => { + it('keeps animations on against the config (--no-disable-animations)', async () => { + expect(await sent({ appPlatform: 'ios', disableAnimations: false })).to.equal(false); + }); + + it('turns animations off against the config (--disable-animations)', async () => { + expect(await sent({ appPlatform: 'android', disableAnimations: true })).to.equal(true); + }); + + it('leaves the config in charge when not passed', async () => { + const androidOff = { + android: { disableAnimations: true }, + ios: { disableAnimations: false }, + }; + expect(await sent({ appPlatform: 'android' }, androidOff)).to.equal(true); + }); + }); + + describe('the --disable-animations flag', () => { + const parse = (argv: string[]) => parseArgs(argv, deviceFlags)['disable-animations']; + + it('is undefined when not passed, so the config can apply', () => { + expect(parse([])).to.equal(undefined); + }); + + it('is true or false when passed either way', () => { + expect(parse(['--disable-animations'])).to.equal(true); + expect(parse(['--no-disable-animations'])).to.equal(false); + }); + }); + + describe('platformFromAppFile', () => { + it('reads the platform from the app file', () => { + expect(platformFromAppFile('build/app.apk')).to.equal('android'); + expect(platformFromAppFile('build/App.app')).to.equal('ios'); + expect(platformFromAppFile('build/app.zip')).to.equal('ios'); + expect(platformFromAppFile('build/expo-build.tar.gz')).to.equal('ios'); + expect(platformFromAppFile('https://expo.dev/build.tar.gz?token=x')).to.equal('ios'); + }); + + it('does not guess from a flow path or nothing', () => { + expect(platformFromAppFile('flows/login.yaml')).to.equal(undefined); + expect(platformFromAppFile(undefined)).to.equal(undefined); + }); + }); +}); diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 8c75f13..9feda29 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { ApiGateway } from '../../src/gateways/api-gateway.js'; import { ReportDownloadService } from '../../src/services/report-download.service.js'; import type { AuthContext } from '../../src/types/domain/auth.types.js'; @@ -457,3 +458,66 @@ describe('ReportDownloadService', () => { }); }); }); + +// The HTML report (html and html-detailed alike) is a ZIP of report.html and +// its assets, so saving it as ./report.html by default produced a file no +// browser could open. +describe('ReportDownloadService default report paths', () => { + const realDownload = ApiGateway.downloadReportGeneric; + let saved: Array<{ filePath?: string; type: string }>; + + beforeEach(() => { + saved = []; + ApiGateway.downloadReportGeneric = (async ( + _baseUrl: string, + _auth: AuthContext, + _uploadId: string, + type: 'allure' | 'html' | 'junit', + filePath?: string, + ) => { + saved.push({ filePath, type }); + }) as typeof ApiGateway.downloadReportGeneric; + }); + + afterEach(() => { + ApiGateway.downloadReportGeneric = realDownload; + }); + + const download = ( + reportType: 'allure' | 'html' | 'html-detailed' | 'junit', + paths: { allurePath?: string; htmlPath?: string; junitPath?: string } = {}, + ) => + new ReportDownloadService().downloadReports({ + apiUrl: 'https://api.example.com', + auth: TEST_AUTH, + reportType, + uploadId: 'u1', + ...paths, + }); + + it('saves html and html-detailed reports to ./report.zip', async () => { + await download('html'); + await download('html-detailed'); + + const zip = path.resolve(process.cwd(), 'report.zip'); + expect(saved).to.deep.equal([ + { filePath: zip, type: 'html' }, + { filePath: zip, type: 'html' }, + ]); + }); + + it('keeps an explicit --html-path exactly as given', async () => { + await download('html', { htmlPath: 'out/report.html' }); + expect(saved[0].filePath).to.equal(path.resolve(process.cwd(), 'out/report.html')); + }); + + it('leaves the junit and allure defaults alone', async () => { + await download('junit'); + await download('allure'); + + expect(saved.map((s) => s.filePath)).to.deep.equal([ + path.resolve(process.cwd(), 'report.xml'), + path.resolve(process.cwd(), 'report.html'), + ]); + }); +}); From e1bd2b2269166901c00f0f72152a256e8ae01201 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:03:24 +0100 Subject: [PATCH 6/9] fix(auth): refresh login sessions in the MCP server and long cloud runs (#192) A dcd login session's access token lasts about an hour, and resolveAuth only refreshes it when a command starts. The MCP server resolved auth once for its whole lifetime, so with a stored session every tool call failed an hour after the server started, until it was restarted. dcd cloud likewise polled, and then downloaded reports, with the headers it started with, so a run that outlasted the token kept failing polls until it gave up. AuthContext now carries the session's expiry. The MCP context re-resolves once the cached session is near expiry, and a failed attempt isn't cached, so the next tool call retries (including after a fresh dcd login). The poll loop, the downloads after it, and dcd_run_cloud_test's wait loop call the new refreshAuth, which re-reads the stored session and refreshes it under the existing lock, so a token another dcd process already rotated is picked up rather than raced for. It keeps the org the command started with, and the realtime socket is handed the new token so it isn't dropped at expiry. A network blip during refresh is retried like any failed poll; a refused refresh token stops the poll at once with a reconnect hint instead of spending the 30-attempt retry budget on it. API-key auth is unchanged. --- CLAUDE.md | 4 +- src/gateways/realtime-gateway.ts | 22 +- src/mcp/context.ts | 24 +- src/mcp/tools/run-cloud-test.ts | 8 +- src/services/report-download.service.ts | 8 +- src/services/results-polling.service.ts | 71 ++++- src/types/domain/auth.types.ts | 6 + src/utils/auth.ts | 61 +++- test/unit/session-refresh.test.ts | 378 ++++++++++++++++++++++++ 9 files changed, 566 insertions(+), 16 deletions(-) create mode 100644 test/unit/session-refresh.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0bf72db..2917e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Cloud command workflow** (the canonical shape other commands mirror): upload binary with SHA dedup → extract metadata / validate devices → build execution plan → submit tests → poll results every 10s → download artifacts (reports/videos/logs). `RunFailedError` from the polling service is the signal for a failed test run (distinct from infra errors). -**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. +**Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. **Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. @@ -49,7 +49,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **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. +**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 again when a `dcd login` session nears expiry (so `tools/list` works unauthenticated, auth errors surface as tool errors rather than a boot crash, and a long-lived server outlasts the one-hour access token) 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 diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index 832c913..49035c5 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -30,6 +30,13 @@ export interface RealtimeResultsSubscription { isConnected(): boolean; /** Tear down the channel and close the socket. Best-effort, never throws. */ unsubscribe(): Promise; + /** + * Hand the socket a refreshed JWT. RLS authorises the channel with the token + * it joined with, and the server drops the channel once that expires — so a + * poll that outlives the token passes the new one on. Best-effort, never + * throws. + */ + updateAccessToken(accessToken: string): void; } export interface RealtimeSubscribeOptions { @@ -134,6 +141,15 @@ export class RealtimeResultsGateway { /* best effort — process is exiting anyway */ } }, + updateAccessToken(nextToken: string) { + // setAuth updates the join payload and pushes the token to joined + // channels. A failure only costs realtime; the backstop poll stays. + activeClient.realtime.setAuth(nextToken).catch((error: unknown) => { + dbg( + `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, }; } catch (error) { dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); @@ -143,7 +159,11 @@ export class RealtimeResultsGateway { } catch { /* ignore */ } - return { isConnected: () => false, async unsubscribe() {} }; + return { + isConnected: () => false, + async unsubscribe() {}, + updateAccessToken() {}, + }; } } } diff --git a/src/mcp/context.ts b/src/mcp/context.ts index 735a2cf..175cbc9 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -1,6 +1,7 @@ /** - * Per-process MCP context: a single resolved AuthContext + API URL, plus the - * stderr logger every tool must use. + * Per-process MCP context: the resolved AuthContext + API URL (re-resolved + * when a `dcd login` session nears expiry), plus the stderr logger every tool + * must use. * * stdio transport reserves **stdout** for the JSON-RPC frame stream — anything * a tool prints there corrupts the protocol. Tools therefore call the services @@ -8,7 +9,7 @@ * can `process.exit`). */ import type { AuthContext } from '../types/domain/auth.types.js'; -import { resolveAuth } from '../utils/auth.js'; +import { isAuthExpiring, resolveAuth } from '../utils/auth.js'; import { resolveApiUrl } from '../utils/config-store.js'; /** Write a line to stderr. Safe under stdio transport; stdout is reserved. */ @@ -24,24 +25,37 @@ export interface McpContext { let cached: McpContext | null = null; /** - * Resolve auth + API URL once per process, lazily on first tool invocation. + * Resolve auth + API URL lazily on first tool invocation, and again whenever a + * `dcd login` session is near expiry. * * Lazy so `initialize` / `tools/list` succeed before the user has supplied a * credential (clients enumerate tools on connect), and so an auth failure * surfaces as a tool error rather than crashing the server at boot. * + * Re-resolved near expiry because a session's access token lasts about an + * hour while an MCP server can run for days: resolving once left every tool + * call failing after the first hour. resolveAuth refreshes the stored session + * (or picks up one another `dcd` process already refreshed). A failed + * refresh leaves the cache alone, so the next call retries — including after + * the user runs `dcd login` again. API keys never expire and are resolved once. + * * Precedence matches the CLI: `DEVICE_CLOUD_API_KEY` env > stored `dcd login` * session. The API URL honors `DCD_API_URL` (handy for pointing the server at * dev/staging), then the logged-in env, then the prod default. */ export async function getContext(): Promise { - if (cached) return cached; + if (cached && !isAuthExpiring(cached.auth)) return cached; const auth = await resolveAuth({ apiKeyFlag: undefined }); const apiUrl = resolveApiUrl(process.env.DCD_API_URL); cached = { apiUrl, auth }; return cached; } +/** Forget the resolved context. For tests. */ +export function clearContextCache(): void { + cached = null; +} + /** * Read-only mode hides the only state-changing / billable tool * (`dcd_run_cloud_test`). Recommended for autonomous or untrusted agents. diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 231b7b9..6382a8d 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -11,6 +11,7 @@ import { platformFromAppFile } from '../../services/notices.service.js'; import { TestSubmissionService } from '../../services/test-submission.service.js'; import { VersionService } from '../../services/version.service.js'; import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js'; +import { refreshAuth } from '../../utils/auth.js'; import { getCliVersion } from '../../utils/cli.js'; import { fetchCompatibilityData } from '../../utils/compatibility.js'; import { isEncryptionEnabled } from '../../utils/envelope.js'; @@ -282,8 +283,13 @@ export function registerRunCloudTest(server: McpServer): void { // the caller can resume with dcd_get_status using the returned uploadId. const deadline = Date.now() + (args.waitTimeoutSeconds ?? 600) * 1000; + let pollAuth = auth; for (;;) { - const status = await ApiGateway.getUploadStatus(apiUrl, auth, { uploadId }); + // A wait of up to an hour can outlast a `dcd login` access token. + pollAuth = await refreshAuth(pollAuth); + const status = await ApiGateway.getUploadStatus(apiUrl, pollAuth, { + uploadId, + }); if (TERMINAL_STATUSES.has(status.status)) { return jsonResult({ uploadId, consoleUrl, status: status.status, tests: status.tests }); } diff --git a/src/services/report-download.service.ts b/src/services/report-download.service.ts index fee0d3a..d1bfc96 100644 --- a/src/services/report-download.service.ts +++ b/src/services/report-download.service.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { ApiGateway } from '../gateways/api-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; +import { refreshAuth } from '../utils/auth.js'; export interface DownloadOptions { auth: AuthContext; @@ -52,9 +53,11 @@ export class ReportDownloadService { logger(`[DEBUG] Downloading artifacts: ${downloadType}`); } + // `dcd cloud` downloads after polling, which can outlast a `dcd login` + // token; a failed refresh lands in the warning below like any failure. await ApiGateway.downloadArtifactsZip( apiUrl, - auth, + await refreshAuth(auth), uploadId, downloadType, artifactsPath, @@ -155,9 +158,10 @@ export class ReportDownloadService { logger(`[DEBUG] Downloading ${type.toUpperCase()} report`); } + // As for artifacts: this can run after a poll that outlasted the token. await ApiGateway.downloadReportGeneric( apiUrl, - auth, + await refreshAuth(auth), uploadId, type, filePath, diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 5b26839..494b1a8 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -8,6 +8,7 @@ import { import { formatDurationSeconds } from '../methods.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import { paths } from '../types/generated/schema.types.js'; +import { isDefinitiveAuthFailure, refreshAuth } from '../utils/auth.js'; import { checkInternetConnectivity } from '../utils/connectivity.js'; import { isCI } from '../utils/ci.js'; import { ux } from '../utils/progress.js'; @@ -28,6 +29,18 @@ export class RunFailedError extends Error { } } +/** + * The `dcd login` session behind a poll expired and only a new login can fix + * it, so the poll stops at once instead of spending its retry budget — meant + * for network blips — on a failure that can't clear. + */ +export class PollingAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'PollingAuthError'; + } +} + export interface PollingOptions { auth: AuthContext; apiUrl: string; @@ -193,7 +206,10 @@ export class ResultsPollingService { options: PollingOptions, testMetadata?: Record, ): Promise { - const { apiUrl, auth, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options; + const { apiUrl, uploadId, consoleUrl, quiet = false, json = false, debug = false, logger } = options; + // Re-checked before every poll: a run can outlast a `dcd login` access + // token (about an hour). API-key auth passes through unchanged. + let { auth } = options; this.initializePollingDisplay(json, logger); @@ -307,6 +323,7 @@ export class ResultsPollingService { nextPollAt = null; renderStatus(); + auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger); const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger); const { summary } = this.calculateStatusSummary(updatedResults); @@ -338,8 +355,9 @@ export class ResultsPollingService { renderStatus(); await waitForNextPoll(pollIntervalMs); } catch (error) { - // Re-throw RunFailedError immediately (test failures, not polling errors) - if (error instanceof RunFailedError) { + // Re-throw RunFailedError immediately (test failures, not polling + // errors), and an expired login no retry can fix. + if (error instanceof RunFailedError || error instanceof PollingAuthError) { throw error; } @@ -564,6 +582,53 @@ export class ResultsPollingService { return updatedResults; } + /** + * Swap in a fresh `dcd login` token when the current one is about to + * expire, and hand it to the realtime socket too; API-key auth comes back + * as-is. A transient refresh failure is thrown as-is, to be retried like + * any failed poll; one only `dcd login` can fix becomes a PollingAuthError. + * @param auth The credential the previous poll used + * @param uploadId Upload being polled, for the reconnect hint + * @param subscription Realtime subscription to pass a new token to + * @param debug Whether debug logging is enabled + * @param logger Optional logger function + * @returns The credential for the next poll + */ + private async refreshPollingAuth( + auth: AuthContext, + uploadId: string, + subscription: RealtimeResultsSubscription | undefined, + debug: boolean, + logger?: (message: string) => void, + ): Promise { + let next: AuthContext; + try { + next = await refreshAuth(auth); + } catch (error) { + if (isDefinitiveAuthFailure(error)) { + const reason = error instanceof Error ? error.message : String(error); + throw new PollingAuthError( + `Your dcd login session expired and could not be refreshed: ${reason}\n\n` + + `The test is still running in the cloud. Run \`dcd login\`, then reconnect with:\n dcd status --upload-id ${uploadId}`, + ); + } + + throw error; + } + + if (next !== auth) { + if (debug && logger) { + logger('[DEBUG] Refreshed the dcd login session for polling'); + } + + if (next.accessToken && next.accessToken !== auth.accessToken) { + subscription?.updateAccessToken(next.accessToken); + } + } + + return next; + } + private filterLatestResults(results: TestResult[]): TestResult[] { // Resolve each row to the root of its retry chain (a retry's `retry_of` // may point at the previous retry rather than the original attempt), then diff --git a/src/types/domain/auth.types.ts b/src/types/domain/auth.types.ts index 922d72c..716029e 100644 --- a/src/types/domain/auth.types.ts +++ b/src/types/domain/auth.types.ts @@ -14,6 +14,12 @@ export interface AuthContext { accessToken?: string; /** Environment the session belongs to — present when mode === 'bearer'. */ env?: DcdEnvName; + /** + * Unix epoch seconds at which `accessToken` expires — present when + * mode === 'bearer'. Long-lived callers check it (utils/auth.ts + * isAuthExpiring / refreshAuth) because the headers below stop working then. + */ + expiresAt?: number; /** Present when mode === 'bearer'. */ orgId?: string; /** Present when mode === 'bearer'. */ diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 21aecac..2b94984 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -4,8 +4,9 @@ * Supabase session (refreshed if near expiry). Throws CliError if none found. * * The refresh path writes the rotated tokens back to disk atomically. Callers - * use the returned AuthContext for the duration of the command — one resolve - * per invocation, not per request. + * resolve once per invocation, not per request — except where a process can + * outlive the session's access token (the MCP server, a long `dcd cloud` + * poll), which re-checks with isAuthExpiring / refreshAuth below. */ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; @@ -96,6 +97,7 @@ export async function resolveAuth( mode: 'bearer', accessToken: session.access_token, env: config.env, + expiresAt: session.expires_at, orgId: config.current_org_id, userEmail: session.user_email, headers: { @@ -107,6 +109,61 @@ export async function resolveAuth( return auth; } +/** + * True when a `dcd login` (bearer) context's access token has expired or will + * within the refresh window. API-key contexts never expire. + */ +export function isAuthExpiring( + auth: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): boolean { + return ( + auth.mode === 'bearer' && + auth.expiresAt !== undefined && + auth.expiresAt <= nowSeconds + REFRESH_SKEW_SECONDS + ); +} + +/** + * Keep a long-running command's credential usable. An API-key context, or a + * session that isn't near expiry, comes back unchanged. Otherwise the stored + * session is re-read and, if it is expiring too, refreshed under the same lock + * resolveAuth uses — so a session another `dcd` process has already rotated + * is picked up rather than raced for. + * + * Only the token changes: the org the command started with is kept, so an + * org switched in another terminal mid-run can't redirect an in-flight poll + * at an upload that org doesn't own. + * + * @throws SessionRefreshError when Supabase refuses the refresh (`definitive` + * when only `dcd login` can fix it), or CliError when there is no stored + * session any more (e.g. after `dcd logout`). + */ +export async function refreshAuth(auth: AuthContext): Promise { + if (!isAuthExpiring(auth)) return auth; + + const fresh = await resolveAuth({ apiKeyFlag: undefined, sessionOnly: true }); + if (!auth.orgId || fresh.orgId === auth.orgId) return fresh; + + return { + ...fresh, + orgId: auth.orgId, + headers: { ...fresh.headers, 'x-dcd-org': auth.orgId }, + }; +} + +/** + * Whether a refreshAuth failure is final for this process — the refresh token + * was refused, or the session is gone — as opposed to a network blip that the + * next attempt may get past. + */ +export function isDefinitiveAuthFailure(error: unknown): boolean { + return ( + (error instanceof SessionRefreshError && error.definitive) || + error instanceof CliError + ); +} + /** * Refresh the stored session under a config-adjacent lockfile so two * concurrent `dcd` invocations (CI matrices) can't both consume the same diff --git a/test/unit/session-refresh.test.ts b/test/unit/session-refresh.test.ts new file mode 100644 index 0000000..c2cf354 --- /dev/null +++ b/test/unit/session-refresh.test.ts @@ -0,0 +1,378 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { ApiGateway } from '../../src/gateways/api-gateway.js'; +import { + CliAuthGateway, + SessionRefreshError, +} from '../../src/gateways/cli-auth-gateway.js'; +import { RealtimeResultsGateway } from '../../src/gateways/realtime-gateway.js'; +import { clearContextCache, getContext } from '../../src/mcp/context.js'; +import { ReportDownloadService } from '../../src/services/report-download.service.js'; +import { + PollingAuthError, + ResultsPollingService, +} from '../../src/services/results-polling.service.js'; +import type { AuthContext } from '../../src/types/domain/auth.types.js'; +import { + isAuthExpiring, + isDefinitiveAuthFailure, + refreshAuth, + resolveAuth, +} from '../../src/utils/auth.js'; +import { CliError } from '../../src/utils/cli.js'; +import { readConfig, writeConfig } from '../../src/utils/config-store.js'; + +/** + * A `dcd login` session's access token lasts about an hour. Anything that can + * run longer — the MCP server, a `dcd cloud` poll and the downloads after it — + * must refresh it rather than keep sending the token it started with. + */ + +const ORIGINAL_ENV = { ...process.env }; +const realNow = Date.now; +const realRefresh = CliAuthGateway.refresh; +const realGetResults = ApiGateway.getResultsForUpload; +const realSubscribe = RealtimeResultsGateway.subscribe; +const realFetch = (global as any).fetch; + +const nowSeconds = () => Math.floor(Date.now() / 1000); + +let tempDir: string; + +/** Write a stored `dcd login` config, as `dcd login` / a refresh would. */ +function storeSession( + accessToken: string, + expiresInSeconds: number, + orgId = '42', +): void { + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: accessToken, + refresh_token: `refresh-for-${accessToken}`, + expires_at: nowSeconds() + expiresInSeconds, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: orgId, + }); +} + +/** A bearer context whose token expired a moment ago. */ +function expiredBearer(accessToken = 'stale-token', orgId = '42'): AuthContext { + return { + mode: 'bearer', + accessToken, + env: 'prod', + expiresAt: nowSeconds() - 5, + orgId, + userEmail: 'u@example.com', + headers: { authorization: `Bearer ${accessToken}`, 'x-dcd-org': orgId }, + }; +} + +/** Fail loudly if a test that shouldn't reach Supabase does. */ +function forbidNetworkRefresh(): void { + CliAuthGateway.refresh = async () => { + throw new Error('unexpected network refresh'); + }; +} + +describe('session refresh for long-running processes', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-refresh-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + delete process.env.DEVICE_CLOUD_API_KEY; + delete process.env.DCD_API_URL; + forbidNetworkRefresh(); + clearContextCache(); + }); + + afterEach(() => { + Date.now = realNow; + CliAuthGateway.refresh = realRefresh; + ApiGateway.getResultsForUpload = realGetResults; + RealtimeResultsGateway.subscribe = realSubscribe; + (global as any).fetch = realFetch; + clearContextCache(); + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + describe('refreshAuth', () => { + it('reports when a resolved session expires', async () => { + storeSession('token-a', 3600); + const auth = await resolveAuth({ apiKeyFlag: undefined }); + expect(auth.expiresAt).to.equal(readConfig()!.session!.expires_at); + expect(isAuthExpiring(auth)).to.equal(false); + expect(isAuthExpiring({ ...auth, expiresAt: nowSeconds() + 30 })).to.equal(true); + }); + + it('never touches API-key auth', async () => { + const auth: AuthContext = { mode: 'apiKey', headers: { 'x-app-api-key': 'k' } }; + expect(isAuthExpiring(auth)).to.equal(false); + expect(await refreshAuth(auth)).to.equal(auth); + }); + + it('returns a session that is not near expiry unchanged', async () => { + storeSession('token-a', 3600); + const auth = await resolveAuth({ apiKeyFlag: undefined }); + expect(await refreshAuth(auth)).to.equal(auth); + }); + + it('picks up a session another dcd process already refreshed, without a refresh call', async () => { + storeSession('rotated-elsewhere', 3600); + const next = await refreshAuth(expiredBearer()); + expect(next.headers.authorization).to.equal('Bearer rotated-elsewhere'); + expect(next.accessToken).to.equal('rotated-elsewhere'); + expect(isAuthExpiring(next)).to.equal(false); + }); + + it('keeps the org the command started with, even if another terminal switched org', async () => { + storeSession('fresh', 3600, '99'); + const next = await refreshAuth(expiredBearer('stale', '42')); + expect(next.orgId).to.equal('42'); + expect(next.headers['x-dcd-org']).to.equal('42'); + expect(next.headers.authorization).to.equal('Bearer fresh'); + }); + + it('refreshes the stored session when it is expiring too, and saves the rotation', async () => { + storeSession('stale', 10); + CliAuthGateway.refresh = async () => ({ + access_token: 'refreshed', + refresh_token: 'refresh-2', + expires_at: nowSeconds() + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }); + + const next = await refreshAuth(expiredBearer('stale')); + expect(next.headers.authorization).to.equal('Bearer refreshed'); + expect(readConfig()!.session!.refresh_token).to.equal('refresh-2'); + }); + + it('classifies a refused refresh token as final and drops the dead session', async () => { + storeSession('stale', 10); + CliAuthGateway.refresh = async () => { + throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); + }; + + let caught: unknown; + try { + await refreshAuth(expiredBearer('stale')); + } catch (error) { + caught = error; + } + + expect(isDefinitiveAuthFailure(caught)).to.equal(true); + expect(readConfig()!.session).to.equal(undefined); + // With the session gone, the next attempt is "not authenticated": final too. + let next: unknown; + try { + await refreshAuth(expiredBearer('stale')); + } catch (error) { + next = error; + } + + expect(next).to.be.instanceOf(CliError); + expect(isDefinitiveAuthFailure(next)).to.equal(true); + }); + + it('treats a network failure during refresh as retryable', () => { + expect(isDefinitiveAuthFailure(new SessionRefreshError('fetch failed', false))).to.equal( + false, + ); + expect(isDefinitiveAuthFailure(new Error('socket hang up'))).to.equal(false); + }); + }); + + describe('MCP context', () => { + it('re-resolves a dcd login session once it nears expiry', async () => { + storeSession('token-a', 3600); + const first = await getContext(); + expect(first.auth.headers.authorization).to.equal('Bearer token-a'); + + // Another process (or a re-login) stores a newer session... + storeSession('token-b', 3 * 3600); + // ...which the server doesn't need while its own token is still good. + expect((await getContext()).auth.headers.authorization).to.equal('Bearer token-a'); + + // Two hours on, token-a has expired and the server picks up token-b. + Date.now = () => realNow() + 2 * 3600 * 1000; + const later = await getContext(); + expect(later.auth.headers.authorization).to.equal('Bearer token-b'); + expect(later.apiUrl).to.equal('https://api.devicecloud.dev'); + }); + + it('resolves an API key once, whatever the time', async () => { + process.env.DEVICE_CLOUD_API_KEY = 'mcp-key'; + const first = await getContext(); + Date.now = () => realNow() + 48 * 3600 * 1000; + expect(await getContext()).to.equal(first); + expect(first.auth.headers['x-app-api-key']).to.equal('mcp-key'); + }); + + it('keeps failing, rather than caching, until the user logs in again', async () => { + storeSession('token-a', 3600); + await getContext(); + fs.rmSync(path.join(tempDir, 'config.json'), { force: true }); + Date.now = () => realNow() + 2 * 3600 * 1000; + + let caught: unknown; + try { + await getContext(); + } catch (error) { + caught = error; + } + + expect((caught as Error)?.message).to.match(/Not authenticated/); + storeSession('after-relogin', 3 * 3600); + expect((await getContext()).auth.headers.authorization).to.equal( + 'Bearer after-relogin', + ); + }); + }); + + describe('dcd cloud polling', () => { + const passedRow = { + id: 1, + test_file_name: 'flow.yaml', + status: 'PASSED', + retry_of: null, + duration_seconds: 1, + fail_reason: null, + }; + + /** Record the headers of every poll and answer with a finished run. */ + function stubResults(): Array> { + const seen: Array> = []; + ApiGateway.getResultsForUpload = (async (_url: string, auth: AuthContext) => { + seen.push({ ...auth.headers }); + return { results: [passedRow] }; + }) as unknown as typeof ApiGateway.getResultsForUpload; + return seen; + } + + /** Stand in for the Supabase socket and record the tokens it is handed. */ + function stubRealtime(): { joinedWith: string[]; updatedTo: string[] } { + const calls = { joinedWith: [] as string[], updatedTo: [] as string[] }; + RealtimeResultsGateway.subscribe = (options) => { + calls.joinedWith.push(options.accessToken); + return { + isConnected: () => false, + async unsubscribe() {}, + updateAccessToken(token: string) { + calls.updatedTo.push(token); + }, + }; + }; + return calls; + } + + const poll = (auth: AuthContext, service = new ResultsPollingService()) => + service.pollUntilComplete({ + auth, + apiUrl: 'https://api.example.com', + consoleUrl: 'https://console.example.com/results?upload=u1', + json: true, + uploadId: 'u1', + }); + + it('polls with a refreshed token, and hands it to the realtime socket', async () => { + storeSession('fresh', 3600, '99'); + const seen = stubResults(); + const realtime = stubRealtime(); + + const result = await poll(expiredBearer('stale', '42')); + + expect(result.status).to.equal('PASSED'); + expect(seen).to.deep.equal([{ authorization: 'Bearer fresh', 'x-dcd-org': '42' }]); + expect(realtime.joinedWith).to.deep.equal(['stale']); + expect(realtime.updatedTo).to.deep.equal(['fresh']); + }); + + it('leaves API-key polling exactly as it was', async () => { + const seen = stubResults(); + await poll({ mode: 'apiKey', headers: { 'x-app-api-key': 'k' } }); + expect(seen).to.deep.equal([{ 'x-app-api-key': 'k' }]); + }); + + it('retries a refresh that failed on a network blip, like any failed poll', async () => { + storeSession('stale', 10); + stubRealtime(); + const seen = stubResults(); + let attempts = 0; + CliAuthGateway.refresh = async () => { + attempts++; + if (attempts === 1) throw new SessionRefreshError('fetch failed', false); + return { + access_token: 'second-try', + refresh_token: 'r2', + expires_at: nowSeconds() + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }; + }; + const service = new ResultsPollingService(); + // Skip the real backoff between failed polls. + (service as unknown as { sleep: () => Promise }).sleep = async () => {}; + + const result = await poll(expiredBearer('stale'), service); + + expect(result.status).to.equal('PASSED'); + expect(attempts).to.equal(2); + expect(seen.map((h) => h.authorization)).to.deep.equal(['Bearer second-try']); + }); + + it('stops at once, with a reconnect hint, when only `dcd login` can help', async () => { + storeSession('stale', 10); + stubRealtime(); + const seen = stubResults(); + CliAuthGateway.refresh = async () => { + throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); + }; + + const started = realNow(); + let caught: unknown; + try { + await poll(expiredBearer('stale')); + } catch (error) { + caught = error; + } + + expect(caught).to.be.instanceOf(PollingAuthError); + expect((caught as Error).message).to.include('Invalid Refresh Token'); + expect((caught as Error).message).to.include('dcd login'); + expect((caught as Error).message).to.include('dcd status --upload-id u1'); + expect(seen).to.deep.equal([]); + // No trip through the 30-attempt retry budget and its backoff. + expect(realNow() - started).to.be.below(2000); + }); + + it('downloads the report after a long poll with a refreshed token', async () => { + storeSession('fresh', 3600); + const headers: Array> = []; + (global as any).fetch = async (_input: unknown, init?: RequestInit) => { + headers.push({ ...(init?.headers as Record) }); + return new Response('', { status: 200 }); + }; + + await new ReportDownloadService().downloadReports({ + apiUrl: 'https://api.example.com', + auth: expiredBearer('stale'), + junitPath: path.join(tempDir, 'report.xml'), + reportType: 'junit', + uploadId: 'u1', + }); + + expect(headers[0].authorization).to.equal('Bearer fresh'); + expect(fs.readFileSync(path.join(tempDir, 'report.xml'), 'utf8')).to.equal(''); + }); + }); +}); From 3fcd96250ca0e40222f0e39709f31ec4000df6e2 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:04:21 +0100 Subject: [PATCH 7/9] feat(cloud): validate devices and OS versions against live API data (#190) dcd cloud rejected any --ios-device, --ios-version, --android-device or --android-api-level value missing from the CLI's hard-coded enums before the API was consulted, so a device the API already runs could not be used until a CLI release listed it (5.5.0 refuses the iPhone 17, iPhone Air, iPhone 18 Pro and M5 iPads). Those values are now checked only against the compatibility data the API returns, which DeviceValidationService already used for the device/version pair; the enums remain as help text. An unknown device still fails before anything is uploaded, and now lists the devices the API offers: Play requests list the Play devices, and a device that exists but has no Play image says so. A lone device or version is still checked against the API's global defaults (iPhone 14 on iOS 17, Pixel 7 on API 34), because the API fills in the same defaults and rejects an incompatible pair; the error now says which default was checked and which flag to pass. If the API stops offering the device the CLI assumes is its default, a version-only request is left to the API instead of being refused against the stale assumption. The MCP run tool gets the same messages, naming its own parameters. --- src/commands/cloud.ts | 38 ++-- src/mcp/tools/run-cloud-test.ts | 11 +- src/services/device-validation.service.ts | 175 +++++++++++++++--- src/types/domain/device.types.ts | 11 +- test/integration/cloud.integration.test.ts | 24 ++- test/unit/device-validation.service.test.ts | 191 ++++++++++++++++++++ 6 files changed, 392 insertions(+), 58 deletions(-) create mode 100644 test/unit/device-validation.service.test.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 2800157..f1edef0 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -27,13 +27,7 @@ import { import { telemetry } from '../services/telemetry.service.js'; import { TestSubmissionService } from '../services/test-submission.service.js'; import { VersionService } from '../services/version.service.js'; -import { - EAndroidApiLevels, - EAndroidDevices, - EiOSDevices, - EiOSVersions, - isIosMatrixConfig, -} from '../types/domain/device.types.js'; +import { isIosMatrixConfig } from '../types/domain/device.types.js'; import { resolveAuth } from '../utils/auth.js'; import { assertMatrixSupported, @@ -206,26 +200,16 @@ export const cloudCommand = defineCommand({ const includeTags = coerceArray( collectRepeatedFlag(rawArgs, ['--include-tags']), ); - const iOSDevice = validateEnum( - args['ios-device'] as string | undefined, - Object.values(EiOSDevices), - 'ios-device', - ); - const iOSVersion = validateEnum( - args['ios-version'] as string | undefined, - Object.values(EiOSVersions), - 'ios-version', - ); - const androidApiLevel = validateEnum( - args['android-api-level'] as string | undefined, - Object.values(EAndroidApiLevels), - 'android-api-level', - ); - const androidDevice = validateEnum( - args['android-device'] as string | undefined, - Object.values(EAndroidDevices), - 'android-device', - ); + // Devices and OS versions are checked against the API's live + // compatibility data further down (DeviceValidationService), not the + // CLI's enums, which only feed --help: a device the API adds must not + // need a CLI release, and one it drops must fail with the API's list. + const iOSDevice = (args['ios-device'] as string | undefined) || undefined; + const iOSVersion = (args['ios-version'] as string | undefined) || undefined; + const androidApiLevel = + (args['android-api-level'] as string | undefined) || undefined; + const androidDevice = + (args['android-device'] as string | undefined) || undefined; const androidNoSnapshot = Boolean(args['android-no-snapshot']); // Repeatable device-matrix flags: one validated cell each, no cross-product. const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']); diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 6382a8d..b54213c 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -116,18 +116,25 @@ export function registerRunCloudTest(server: McpServer): void { const compatibilityData = await fetchCompatibilityData(apiUrl, auth); const deviceValidation = new DeviceValidationService(); + // Hints in validation errors name this tool's parameters, not CLI flags. + const argNames = { + androidApiLevel: 'androidApiLevel', + androidDevice: 'androidDevice', + iOSDevice: 'iosDevice', + iOSVersion: 'iosVersion', + }; deviceValidation.validateiOSDevice( args.iosVersion, args.iosDevice, compatibilityData, - { logger: logStderr }, + { argNames, logger: logStderr }, ); deviceValidation.validateAndroidDevice( args.androidApiLevel, args.androidDevice, Boolean(args.googlePlay), compatibilityData, - { logger: logStderr }, + { argNames, logger: logStderr }, ); const resolvedMaestroVersion = new VersionService().resolveMaestroVersion( diff --git a/src/services/device-validation.service.ts b/src/services/device-validation.service.ts index 0716d80..b3f4a98 100644 --- a/src/services/device-validation.service.ts +++ b/src/services/device-validation.service.ts @@ -1,13 +1,61 @@ -import { EAndroidDevices, EiOSDevices } from '../types/domain/device.types.js'; -import { CompatibilityData } from '../utils/compatibility.js'; +import type { CompatibilityData } from '../utils/compatibility.js'; + +const DEVICES_DOCS_URL = + 'https://docs.devicecloud.dev/getting-started/devices-configuration'; + +/** The device / OS inputs a caller exposes, named for "pass it explicitly" hints. */ +export interface DeviceArgNames { + androidApiLevel: string; + androidDevice: string; + iOSDevice: string; + iOSVersion: string; +} + +/** `dcd cloud`'s spelling; the MCP tool passes its own parameter names. */ +const CLI_ARG_NAMES: DeviceArgNames = { + androidApiLevel: '--android-api-level', + androidDevice: '--android-device', + iOSDevice: '--ios-device', + iOSVersion: '--ios-version', +}; export interface DeviceValidationOptions { + /** How the caller names its inputs in error hints. Defaults to the CLI flags. */ + argNames?: DeviceArgNames; debug?: boolean; logger?: (message: string) => void; } +/** Device slug → the OS versions the API will run it on. */ +type CompatibilityLookup = Record | undefined; + +/** The versions a lookup offers for one device; empty when it offers none. */ +function versionsFor(lookup: CompatibilityLookup, device: string): string[] { + // hasOwn, so a slug like "constructor" can't resolve to a prototype member. + if (!lookup || !Object.hasOwn(lookup, device)) return []; + const versions = lookup[device]; + return Array.isArray(versions) ? versions : []; +} + +/** + * Devices the lookup can actually run, in the API's order. A rollout gate can + * leave a device with no versions (every non-Play device has an empty Google + * Play list), and those must not be offered as alternatives. + */ +function offeredDevices(lookup: CompatibilityLookup): string[] { + return Object.keys(lookup ?? {}).filter( + (device) => versionsFor(lookup, device).length > 0, + ); +} + /** - * Service for validating device configurations against compatibility data + * Validates requested devices against the compatibility matrix the API serves + * from `GET /results/compatibility/data`, which is the only authority on what + * can run. Nothing here consults the CLI's device and OS enums + * (src/types/domain/device.types.ts) — those only feed `--help` — so a device + * or OS version the API adds is accepted without a CLI release, and one it + * withdraws is refused, before anything is uploaded, with the list the API + * offers today. */ export class DeviceValidationService { /** @@ -27,6 +75,11 @@ export class DeviceValidationService { compatibilityData: CompatibilityData, options: DeviceValidationOptions = {}, ): void { + const lookup = googlePlay + ? compatibilityData.androidPlay + : compatibilityData.android; + const argNames = options.argNames ?? CLI_ARG_NAMES; + this.validateDevice({ debugLines: (deviceID, version, supportedVersions) => [ `[DEBUG] Android device: ${deviceID}`, @@ -34,20 +87,31 @@ export class DeviceValidationService { `[DEBUG] Google Play enabled: ${googlePlay}`, `[DEBUG] Supported Android versions: ${supportedVersions.join(', ')}`, ], + // Mirrors the API, which applies these same global defaults to a + // request that omits either half (test-request-validator.service.ts) + // and then rejects an incompatible pair. defaultDevice: 'pixel-7', defaultVersion: '34', - device: androidDevice as EAndroidDevices | undefined, - lookup: googlePlay - ? compatibilityData.androidPlay - : compatibilityData.android, - noSupportMessage: () => - `We don't support that device configuration - please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, + device: androidDevice, + deviceArg: argNames.androidDevice, + deviceKind: googlePlay ? 'Google Play' : 'Android', + formatVersion: (version) => `API level ${version}`, + lookup, options, + platform: 'Android', + // A real device that merely has no Google Play image deserves a sharper + // answer than "not supported". + unknownDeviceMessage: (deviceID) => + googlePlay && versionsFor(compatibilityData.android, deviceID).length > 0 + ? `Android device "${deviceID}" is not available with Google Play. Google Play devices: ${offeredDevices(lookup).join(', ')}. See ${DEVICES_DOCS_URL}` + : undefined, unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} ${ googlePlay ? '(Play Store) ' : '' }only supports these Android API levels: ${supportedVersions.join(', ')}`, version: androidApiLevel, + versionArg: argNames.androidApiLevel, + versionName: 'Android API level', }); } @@ -66,22 +130,29 @@ export class DeviceValidationService { compatibilityData: CompatibilityData, options: DeviceValidationOptions = {}, ): void { + const argNames = options.argNames ?? CLI_ARG_NAMES; + this.validateDevice({ debugLines: (deviceID, version, supportedVersions) => [ `[DEBUG] iOS device: ${deviceID}`, `[DEBUG] iOS version: ${version}`, `[DEBUG] Supported iOS versions: ${supportedVersions.join(', ')}`, ], + // Mirrors the API's global defaults, as for Android above. defaultDevice: 'iphone-14', defaultVersion: '17', - device: iOSDevice as EiOSDevices | undefined, + device: iOSDevice, + deviceArg: argNames.iOSDevice, + deviceKind: 'iOS', + formatVersion: (version) => `iOS ${version}`, lookup: compatibilityData?.ios, - noSupportMessage: (deviceID) => - `Device ${deviceID} is not supported. Please check the docs for supported devices: https://docs.devicecloud.dev/getting-started/devices-configuration`, options, + platform: 'iOS', unsupportedVersionMessage: (deviceID, supportedVersions) => `${deviceID} only supports these iOS versions: ${supportedVersions.join(', ')}`, version: iOSVersion, + versionArg: argNames.iOSVersion, + versionName: 'iOS version', }); } @@ -101,25 +172,43 @@ export class DeviceValidationService { defaultDevice: string; defaultVersion: string; device: string | undefined; - lookup: Record | undefined; - noSupportMessage: (deviceID: string) => string; + /** How the caller names the device input, e.g. "--ios-device". */ + deviceArg: string; + /** What the offered-device list is called: "iOS", "Android", "Google Play". */ + deviceKind: string; + /** One version in prose, e.g. "iOS 27" or "API level 37". */ + formatVersion: (version: string) => string; + lookup: CompatibilityLookup; options: DeviceValidationOptions; + platform: 'Android' | 'iOS'; + /** Replaces the generic unknown-device message when it returns a string. */ + unknownDeviceMessage?: (deviceID: string) => string | undefined; unsupportedVersionMessage: ( deviceID: string, supportedVersions: string[], ) => string; version: string | undefined; + /** How the caller names the version input, e.g. "--ios-version". */ + versionArg: string; + /** e.g. "iOS version" / "Android API level". */ + versionName: string; }): void { const { debugLines, defaultDevice, defaultVersion, device, + deviceArg, + deviceKind, + formatVersion, lookup, - noSupportMessage, options, + platform, + unknownDeviceMessage, unsupportedVersionMessage, version, + versionArg, + versionName, } = config; const { debug = false, logger } = options; @@ -128,18 +217,60 @@ export class DeviceValidationService { } const deviceID = device || defaultDevice; - const supportedVersions: string[] = lookup?.[deviceID] || []; + const supportedVersions = versionsFor(lookup, deviceID); const requestedVersion = version || defaultVersion; if (supportedVersions.length === 0) { - throw new Error(noSupportMessage(deviceID)); + if (!device) { + // Only a version was requested, and the API no longer offers the + // device the CLI assumes it defaults to. That assumption is what's + // stale, not the request: the API resolves and validates its own + // default device when the run is submitted. + if (debug && logger) { + logger( + `[DEBUG] Default ${platform} device ${deviceID} is not in the compatibility data; leaving the ${versionName} check to the API`, + ); + } + + return; + } + + const offered = offeredDevices(lookup); + throw new Error( + unknownDeviceMessage?.(deviceID) ?? + (offered.length > 0 + ? `${platform} device "${deviceID}" is not supported. Supported ${deviceKind} devices: ${offered.join(', ')}. See ${DEVICES_DOCS_URL}` + : `${platform} device "${deviceID}" is not supported. See ${DEVICES_DOCS_URL}`), + ); } - if ( - Array.isArray(supportedVersions) && - !supportedVersions.includes(requestedVersion) - ) { - throw new Error(unsupportedVersionMessage(deviceID, supportedVersions)); + if (!supportedVersions.includes(requestedVersion)) { + const unsupported = unsupportedVersionMessage(deviceID, supportedVersions); + + if (!version) { + // Device-only request. The API fills in the same global default + // version rather than one this device runs, so the submission would + // be refused there too — say so, and how to avoid it. + throw new Error( + `${unsupported}. No ${versionName} was given, so the default (${defaultVersion}) was checked: pass one of those explicitly with ${versionArg}`, + ); + } + + if (!device) { + // Version-only request: name the device that was assumed, and the + // devices that do offer the requested version. + const devicesWithVersion = offeredDevices(lookup).filter((d) => + versionsFor(lookup, d).includes(requestedVersion), + ); + throw new Error( + `${unsupported}. No ${platform} device was given, so the default (${deviceID}) was checked: ` + + (devicesWithVersion.length > 0 + ? `pass one that offers ${formatVersion(requestedVersion)} with ${deviceArg}: ${devicesWithVersion.join(', ')}` + : `no ${deviceKind} device offers ${formatVersion(requestedVersion)}`), + ); + } + + throw new Error(unsupported); } if (debug && logger) { diff --git a/src/types/domain/device.types.ts b/src/types/domain/device.types.ts index 6bbe915..8715385 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -2,11 +2,12 @@ * Device type definitions - should be kept in sync with API * @see dcd/api/src/common/types/device.types.ts * - * These drive `--ios-device` / `--android-device` help text and nothing else: - * the actual validation runs against the live compatibility payload - * (device-validation.service.ts), so a value the API has dropped produces a - * clear "not supported" error rather than a wrong local rejection. That makes - * a CLI *ahead* of the API safe and a CLI *behind* it not. + * These drive the `--ios-device` / `--ios-version` / `--android-device` / + * `--android-api-level` help text and nothing else: validation runs against + * the live compatibility payload (device-validation.service.ts). A value the + * API has dropped gets a "not supported" error listing what it offers now, and + * one it has added is accepted even if it is missing here — so keeping these + * lists current only improves the help text; it never gates a run. * * `iphone-14-pro` and `iphone-15-pro` were listed here but have never existed * in the API enum; removed 2026-09-21 rather than carried forward. diff --git a/test/integration/cloud.integration.test.ts b/test/integration/cloud.integration.test.ts index 39d77d5..08a7806 100644 --- a/test/integration/cloud.integration.test.ts +++ b/test/integration/cloud.integration.test.ts @@ -158,8 +158,28 @@ appId: com.example.app const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device unsupported-device --ios-version 99`; const { output } = await runExpectingFailure(command); - expect(output).to.include('Invalid value for --ios-device'); - expect(output).to.include('unsupported-device'); + expect(output).to.include( + 'iOS device "unsupported-device" is not supported', + ); + // The alternatives come from the API's compatibility data, not the + // CLI's help-text enum. + expect(output).to.include('Supported iOS devices: '); + expect(output).to.include('iphone-17'); + }); + + it('accepts a device from the API compatibility data (dry run)', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device iphone-17 --ios-version 26 --dry-run`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + expect(stdout).to.include('Dry run mode'); + }); + + it('asks for the version when a lone device needs a non-default one', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device iphone-17 --dry-run`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('iphone-17 only supports these iOS versions'); + expect(output).to.include('--ios-version'); }); }); diff --git a/test/unit/device-validation.service.test.ts b/test/unit/device-validation.service.test.ts new file mode 100644 index 0000000..db5c104 --- /dev/null +++ b/test/unit/device-validation.service.test.ts @@ -0,0 +1,191 @@ +import { expect } from 'chai'; + +import { DeviceValidationService } from '../../src/services/device-validation.service.js'; +import { EiOSDevices } from '../../src/types/domain/device.types.js'; +import type { CompatibilityData } from '../../src/utils/compatibility.js'; + +/** + * Device names are validated against the API's live compatibility data, never + * the CLI's help-text enums. `iphone-19` and `pixel-12` stand in for devices + * the API has added since this CLI was released. + */ +const compat: CompatibilityData = { + ios: { + 'iphone-14': ['17', '18'], + 'iphone-17': ['26', '27'], + 'iphone-19': ['28'], + // A rollout gate can leave a device with nothing bookable. + 'iphone-gated': [], + }, + android: { + 'pixel-7': ['33', '34', '35', '36', '37'], + 'pixel-10': ['36', '37'], + 'pixel-12': ['38'], + }, + androidPlay: { + 'pixel-7': ['34'], + 'pixel-10': [], + 'pixel-12': [], + }, + maestro: { + defaultVersion: '2.2.0', + latestVersion: '2.10.0', + supportedVersions: ['2.2.0', '2.10.0'], + }, +}; + +const MCP_ARG_NAMES = { + androidApiLevel: 'androidApiLevel', + androidDevice: 'androidDevice', + iOSDevice: 'iosDevice', + iOSVersion: 'iosVersion', +}; + +describe('DeviceValidationService', () => { + const service = new DeviceValidationService(); + const ios = (version?: string, device?: string, data = compat) => + service.validateiOSDevice(version, device, data); + const android = ( + apiLevel?: string, + device?: string, + googlePlay = false, + data = compat, + ) => service.validateAndroidDevice(apiLevel, device, googlePlay, data); + + describe('devices the CLI does not know about', () => { + it('accepts an iOS device and version the API offers but the CLI enum lacks', () => { + expect(Object.values(EiOSDevices)).to.not.include('iphone-19'); + expect(() => ios('28', 'iphone-19')).to.not.throw(); + }); + + it('accepts an Android device and API level the API offers but the CLI enum lacks', () => { + expect(() => android('38', 'pixel-12')).to.not.throw(); + }); + }); + + describe('unknown devices', () => { + it('rejects an unknown iOS device, listing what the API offers in its order', () => { + expect(() => ios('17', 'iphone-99')).to.throw( + 'iOS device "iphone-99" is not supported. Supported iOS devices: iphone-14, iphone-17, iphone-19. See https://docs.devicecloud.dev/getting-started/devices-configuration', + ); + }); + + it('never offers a gated device, and treats it as unsupported', () => { + expect(() => ios('17', 'iphone-gated')).to.throw( + /iOS device "iphone-gated" is not supported\. Supported iOS devices: iphone-14, iphone-17, iphone-19\./, + ); + }); + + it('rejects an unknown Android device, listing the Android devices', () => { + expect(() => android('34', 'pixel-99')).to.throw( + /Android device "pixel-99" is not supported\. Supported Android devices: pixel-7, pixel-10, pixel-12\./, + ); + }); + + it('says a real device has no Google Play image, listing the Play devices', () => { + expect(() => android('36', 'pixel-10', true)).to.throw( + /Android device "pixel-10" is not available with Google Play\. Google Play devices: pixel-7\./, + ); + }); + + it('lists only Google Play devices for an unknown device with --google-play', () => { + expect(() => android('34', 'pixel-99', true)).to.throw( + /Android device "pixel-99" is not supported\. Supported Google Play devices: pixel-7\./, + ); + }); + + it('does not resolve a prototype member as a device', () => { + expect(() => ios('17', 'constructor')).to.throw( + /iOS device "constructor" is not supported/, + ); + }); + + it('still fails when the API sent no list at all, without an empty list', () => { + const noIos = { ...compat, ios: {} }; + expect(() => ios('17', 'iphone-14', noIos)).to.throw( + 'iOS device "iphone-14" is not supported. See https://docs.devicecloud.dev/getting-started/devices-configuration', + ); + }); + }); + + describe('versions', () => { + it('accepts a supported pair', () => { + expect(() => ios('26', 'iphone-17')).to.not.throw(); + expect(() => android('34', 'pixel-7', true)).to.not.throw(); + }); + + it('rejects an unsupported explicit pair with the device\'s versions', () => { + expect(() => ios('27', 'iphone-14')).to.throw( + /^iphone-14 only supports these iOS versions: 17, 18$/, + ); + expect(() => android('34', 'pixel-10')).to.throw( + /^pixel-10 only supports these Android API levels: 36, 37$/, + ); + }); + + it('does nothing when neither a device nor a version was requested', () => { + expect(() => ios()).to.not.throw(); + expect(() => android(undefined, undefined, true)).to.not.throw(); + }); + }); + + // The API fills an omitted half from the same global defaults (iphone-14 / + // iOS 17, pixel-7 / API 34) and rejects an incompatible pair, so the CLI + // mirrors that — but must tell the user to pass the other half. + describe('requests that rely on a default', () => { + it('tells a device-only iOS request to pass the version explicitly', () => { + expect(() => ios(undefined, 'iphone-17')).to.throw( + 'iphone-17 only supports these iOS versions: 26, 27. No iOS version was given, so the default (17) was checked: pass one of those explicitly with --ios-version', + ); + }); + + it('tells a device-only Android request to pass the API level explicitly', () => { + expect(() => android(undefined, 'pixel-10')).to.throw( + 'pixel-10 only supports these Android API levels: 36, 37. No Android API level was given, so the default (34) was checked: pass one of those explicitly with --android-api-level', + ); + }); + + it('accepts a device-only request when the default version fits', () => { + expect(() => ios(undefined, 'iphone-14')).to.not.throw(); + expect(() => android(undefined, 'pixel-7')).to.not.throw(); + }); + + it('names the MCP tool parameters when asked to', () => { + expect(() => + service.validateiOSDevice(undefined, 'iphone-17', compat, { + argNames: MCP_ARG_NAMES, + }), + ).to.throw(/pass one of those explicitly with iosVersion$/); + expect(() => + service.validateAndroidDevice('38', undefined, false, compat, { + argNames: MCP_ARG_NAMES, + }), + ).to.throw(/with androidDevice: pixel-12$/); + }); + + it('points a version-only request at the devices that offer that version', () => { + expect(() => ios('27')).to.throw( + 'iphone-14 only supports these iOS versions: 17, 18. No iOS device was given, so the default (iphone-14) was checked: pass one that offers iOS 27 with --ios-device: iphone-17', + ); + }); + + it('says when no device offers the requested version at all', () => { + expect(() => ios('99')).to.throw( + /No iOS device was given, so the default \(iphone-14\) was checked: no iOS device offers iOS 99$/, + ); + expect(() => android('36', undefined, true)).to.throw( + /no Google Play device offers API level 36$/, + ); + }); + + it('leaves a version-only request to the API once the API no longer offers the assumed default device', () => { + // The API has moved its default on; checking against the CLI's stale + // assumption would refuse a request the API accepts. + const withoutPixel7 = { + ...compat, + android: { 'pixel-10': ['36', '37'] }, + }; + expect(() => android('36', undefined, false, withoutPixel7)).to.not.throw(); + }); + }); +}); From bbe3f60a80aa13a4496ef35ac6ced8ad1fea388d Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:07:25 +0100 Subject: [PATCH 8/9] fix(whoami): say when an API key overrides the stored session (#194) * fix(whoami): say when an API key overrides the stored session whoami and switch-org show and change only the stored dcd login session, but every other command authenticates with --api-key or DEVICE_CLOUD_API_KEY first. With a key exported, whoami reported a user and org that the next dcd cloud would not run as, and switch-org appeared to move commands to an org they never used. whoami now accepts --api-key, and when a key is present from either source it says that other commands use that key and its org rather than the session; logged out, it says the key will be used instead of only suggesting dcd login. switch-org warns the same way when DEVICE_CLOUD_API_KEY is exported. The precedence check lives beside resolveAuth so the two cannot drift. * refactor(whoami): reuse the shared api-key flag Spread apiFlags['api-key'] and override only the description, per the flag-composition rule, instead of redefining type and alias inline. --- src/commands/switch-org.ts | 11 +++- src/commands/whoami.ts | 38 ++++++++++++- src/utils/auth.ts | 13 +++++ test/unit/whoami.test.ts | 114 +++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 test/unit/whoami.test.ts diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 83d155f..969e97f 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -7,7 +7,7 @@ */ import { defineCommand } from 'citty'; -import { resolveAuth } from '../utils/auth.js'; +import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js'; import { fetchOrgs, pickOrg, OrgListItem } from '../utils/orgs.js'; @@ -61,6 +61,15 @@ export const switchOrgCommand = defineCommand({ }); logger.log(ui.success(`Switched to ${colors.highlight(chosen.name)}`)); + + // The switch only changes the stored session; an exported key still wins. + if (apiKeyOverride() === 'DEVICE_CLOUD_API_KEY') { + logger.log( + ui.warn( + `${colors.highlight('DEVICE_CLOUD_API_KEY')} is set, so other dcd commands still authenticate with that API key and its org, not ${chosen.name}. Unset it to use the session.`, + ), + ); + } }, }); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index e6139a1..3dab8c9 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,8 +1,14 @@ /** * `dcd whoami` — prints the logged-in user + active org from stored config. + * + * It reads only the stored `dcd login` session, while every other command + * prefers an API key from --api-key or DEVICE_CLOUD_API_KEY — so when one is + * present it says so, rather than implying the session is what they use. */ import { defineCommand } from 'citty'; +import { apiFlags } from '../config/flags/api.flags.js'; +import { apiKeyOverride } from '../utils/auth.js'; import { logger } from '../utils/cli.js'; import { readConfig } from '../utils/config-store.js'; import { colors, formatId } from '../utils/styling.js'; @@ -13,12 +19,26 @@ export const whoamiCommand = defineCommand({ name: 'whoami', description: 'Show the logged-in user and active organization', }, - run() { + args: { + 'api-key': { + ...apiFlags['api-key'], + description: + 'An API key as you would pass it to other commands; whoami says that it takes precedence over the stored session', + }, + }, + run({ args }) { const config = readConfig(); + const keySource = apiKeyOverride(args['api-key'] as string | undefined); + const keyName = colors.highlight(keySource ?? ''); + if (!config?.session) { logger.log( ui.info( - `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`, + keySource === 'DEVICE_CLOUD_API_KEY' + ? `Not logged in. ${keyName} is set, so dcd commands authenticate with that API key.` + : keySource === '--api-key' + ? `Not logged in. Commands given ${keyName} authenticate with that API key.` + : `Not logged in. Run ${colors.highlight('dcd login')} or set ${colors.highlight('DEVICE_CLOUD_API_KEY')}.`, ), ); return; @@ -35,6 +55,20 @@ export const whoamiCommand = defineCommand({ logger.log(ui.section('devicecloud.dev')); logger.log(ui.branch(ui.fields(fields))); + + if (keySource === 'DEVICE_CLOUD_API_KEY') { + logger.log( + ui.warn( + `${keyName} is set, so other dcd commands authenticate with that API key and its org, not this session. Unset it to use the session.`, + ), + ); + } else if (keySource === '--api-key') { + logger.log( + ui.warn( + `Commands given ${keyName} authenticate with that API key and its org, not this session.`, + ), + ); + } }, }); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2b94984..2b4c146 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -46,6 +46,19 @@ export interface ResolveAuthOptions { sessionOnly?: boolean; } +/** + * Which API key, if any, outranks the stored session — the precedence + * resolveAuth applies. For the session-only commands (`whoami`, `switch-org`) + * to say that what they show is not what other commands will authenticate as. + */ +export function apiKeyOverride( + apiKeyFlag?: string, +): '--api-key' | 'DEVICE_CLOUD_API_KEY' | undefined { + if (apiKeyFlag?.trim()) return '--api-key'; + if (process.env.DEVICE_CLOUD_API_KEY?.trim()) return 'DEVICE_CLOUD_API_KEY'; + return undefined; +} + export async function resolveAuth( opts: ResolveAuthOptions, ): Promise { diff --git a/test/unit/whoami.test.ts b/test/unit/whoami.test.ts new file mode 100644 index 0000000..5967ba7 --- /dev/null +++ b/test/unit/whoami.test.ts @@ -0,0 +1,114 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +import { whoamiCommand } from '../../src/commands/whoami.js'; +import { apiKeyOverride } from '../../src/utils/auth.js'; +import { writeConfig } from '../../src/utils/config-store.js'; + +/** + * `whoami` shows the stored `dcd login` session, but every other command + * authenticates with --api-key or DEVICE_CLOUD_API_KEY first — so it must say + * when one of those is in play instead of implying the session is used. + */ +describe('dcd whoami', () => { + const ORIGINAL_ENV = { ...process.env }; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-whoami-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + delete process.env.DEVICE_CLOUD_API_KEY; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + const logIn = () => + writeConfig({ + version: 1, + env: 'prod', + api_url: 'https://api.devicecloud.dev', + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'a', + refresh_token: 'r', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: '42', + current_org_name: 'Acme', + }); + + /** Run whoami and return what it printed, without colour codes. */ + function whoami(args: Record = {}): string { + let out = ''; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; + try { + (whoamiCommand.run as (ctx: { args: Record }) => void)({ args }); + } finally { + process.stdout.write = original; + } + + return stripVTControlCharacters(out); + } + + it('shows the session and nothing else when no API key is set', () => { + logIn(); + const out = whoami(); + expect(out).to.include('u@example.com'); + expect(out).to.include('Acme'); + expect(out).to.not.match(/API key/); + }); + + it('warns that an exported DEVICE_CLOUD_API_KEY is what other commands use', () => { + logIn(); + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + const out = whoami(); + expect(out).to.include('u@example.com'); + expect(out).to.include( + 'DEVICE_CLOUD_API_KEY is set, so other dcd commands authenticate with that API key and its org, not this session.', + ); + }); + + it('warns about --api-key the same way', () => { + logIn(); + const out = whoami({ 'api-key': 'flag-key' }); + expect(out).to.include( + 'Commands given --api-key authenticate with that API key and its org, not this session.', + ); + }); + + it('says a key will be used when nobody is logged in', () => { + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + expect(whoami()).to.include( + 'Not logged in. DEVICE_CLOUD_API_KEY is set, so dcd commands authenticate with that API key.', + ); + }); + + it('still points a logged-out user without a key at dcd login', () => { + expect(whoami()).to.include('Not logged in. Run dcd login or set DEVICE_CLOUD_API_KEY.'); + }); + + describe('apiKeyOverride', () => { + it('follows resolveAuth: the flag, then the env var', () => { + process.env.DEVICE_CLOUD_API_KEY = 'exported-key'; + expect(apiKeyOverride('flag-key')).to.equal('--api-key'); + expect(apiKeyOverride()).to.equal('DEVICE_CLOUD_API_KEY'); + }); + + it('ignores blank values, as resolveAuth does', () => { + process.env.DEVICE_CLOUD_API_KEY = ' '; + expect(apiKeyOverride(' ')).to.equal(undefined); + }); + }); +}); From 72eb5bfcf6d3e0f5a73ed943469cc18effa3f604 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:12:06 +0100 Subject: [PATCH 9/9] fix(mcp): correct the npx setup command (#191) * fix(mcp): correct the npx setup command The README told MCP clients to run `npx -y @devicecloud.dev/dcd dcd-mcp`. npx runs a package's default bin, which here is `dcd`, so that command ran `dcd dcd-mcp` and printed the CLI's usage instead of starting the server. The server is the package's second bin and has to be named with `npx -y --package=@devicecloud.dev/dcd dcd-mcp`. server.json had the same problem in registry form: clients assemble `npx @ `, so a `dcd-mcp` package argument reached the `dcd` bin too. The manifest now puts `-y`, `--package @` and `dcd-mcp` in runtimeArguments; the identifier a client appends after them is an extra argument that dcd-mcp ignores. Its version was still the 5.0.0-beta.0 it was written with and nothing kept it current, so it now carries the last stable release and release-please rewrites all three pins on each stable release. It also moves to the current registry schema (the old schema URL no longer resolves), which caps the description at 100 characters. The dcd_list_devices tool description also had its mapping inverted: each platform maps a device to the OS versions it runs, not the other way round. * fix(mcp): let registry clients supply the --package version release-please's json updater replaces the whole matched value, so the extra-files entry pointing at the --package argument would have rewritten '@devicecloud.dev/dcd@5.5.0' to a bare '5.6.0' on the next release and broken the command again. server.json now ends runtimeArguments with a bare --package, so the '@' a registry client appends becomes its value, and dcd-mcp moves to packageArguments. The pin lives only in packages[].version, which release-please already rewrites, so that extra-files entry is dropped. The assembled 'npx -y --package @devicecloud.dev/dcd@5.5.0 dcd-mcp' was checked to start the server. The unit test now guards that no argument embeds a version and that release-please only targets bare version fields. --- README.md | 6 +- release-please-config.json | 6 +- server.json | 27 +++++-- src/mcp/tools/list-devices.ts | 2 +- test/integration/mcp.integration.test.ts | 16 ++++ test/unit/server-json.test.ts | 98 ++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 test/unit/server-json.test.ts diff --git a/README.md b/README.md index fdb4804..04b861f 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,15 @@ Add it to your MCP client config: "mcpServers": { "devicecloud": { "command": "npx", - "args": ["-y", "@devicecloud.dev/dcd", "dcd-mcp"], + "args": ["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp"], "env": { "DEVICE_CLOUD_API_KEY": "" } } } } ``` +`--package` matters: the package's default bin is `dcd`, so `npx @devicecloud.dev/dcd dcd-mcp` would run `dcd dcd-mcp` (an unknown command) instead of the server. + Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd login` once and the server picks up the stored session. Point it at a non-prod environment with `DCD_API_URL`. **Tools** @@ -64,7 +66,7 @@ Auth is inherited from the CLI: set `DEVICE_CLOUD_API_KEY` as above, or run `dcd | `dcd_download_artifacts` | Download a run's artifacts/report to disk | | `dcd_run_cloud_test` | Submit a flow to run on the cloud (**billable**) | -**Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` in `args`, or set `DCD_MCP_READONLY=1` in `env`. +**Read-only mode.** `dcd_run_cloud_test` consumes test minutes, so it is annotated as non-read-only/destructive (clients can prompt before calling it). To hide it entirely — recommended for autonomous or untrusted agents — pass `--read-only` at the end of `args` (`["-y", "--package=@devicecloud.dev/dcd", "dcd-mcp", "--read-only"]`), or set `DCD_MCP_READONLY=1` in `env`. By default `dcd_run_cloud_test` is async: it returns an `uploadId` immediately, which you poll with `dcd_get_status`. Pass `wait: true` (bounded by `waitTimeoutSeconds`) to block until completion, or `dryRun: true` to preview the flows without submitting. diff --git a/release-please-config.json b/release-please-config.json index acbb339..4869ae1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -24,7 +24,11 @@ "packages": { ".": { "package-name": "@devicecloud.dev/dcd", - "changelog-path": "CHANGELOG.md" + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "json", "path": "server.json", "jsonpath": "$.version" }, + { "type": "json", "path": "server.json", "jsonpath": "$.packages[*].version" } + ] } } } diff --git a/server.json b/server.json index a2e1b92..7b6df2e 100644 --- a/server.json +++ b/server.json @@ -1,8 +1,8 @@ { - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.json", + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", - "description": "Run Maestro mobile app tests on devicecloud.dev — submit flows, check status, and download artifacts.", - "version": "5.0.0-beta.0", + "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", + "version": "5.5.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,12 +11,27 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.0.0-beta.0", + "version": "5.5.0", "runtimeHint": "npx", - "transport": { "type": "stdio" }, + "runtimeArguments": [ + { + "type": "positional", + "value": "-y" + }, + { + "type": "named", + "name": "--package" + } + ], "packageArguments": [ - { "type": "positional", "value": "dcd-mcp", "valueHint": "dcd-mcp" } + { + "type": "positional", + "value": "dcd-mcp" + } ], + "transport": { + "type": "stdio" + }, "environmentVariables": [ { "name": "DEVICE_CLOUD_API_KEY", diff --git a/src/mcp/tools/list-devices.ts b/src/mcp/tools/list-devices.ts index dc746d1..1b8047c 100644 --- a/src/mcp/tools/list-devices.ts +++ b/src/mcp/tools/list-devices.ts @@ -17,7 +17,7 @@ export function registerListDevices(server: McpServer): void { description: 'List the iOS and Android devices, OS versions, and Maestro versions available on devicecloud.dev. ' + 'Use this to discover valid device/version values before submitting a test run. ' + - 'Returns: { ios, android, androidPlay, maestro }, where each platform maps OS version → supported device list.', + 'Returns: { ios, android, androidPlay, maestro }, where each platform maps a device slug to the OS versions it runs.', inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: true }, }, diff --git a/test/integration/mcp.integration.test.ts b/test/integration/mcp.integration.test.ts index 9ef4b96..5553cda 100644 --- a/test/integration/mcp.integration.test.ts +++ b/test/integration/mcp.integration.test.ts @@ -72,6 +72,22 @@ describe('MCP Server Integration Tests', () => { } }); + // server.json has registry clients pass `@` as the + // value of --package, but a client that assembles argv differently could + // still hand it to dcd-mcp, so the server must shrug off a stray positional. + it('ignores the package spec a registry client appends to argv', async () => { + const { client, ready } = connect(['@devicecloud.dev/dcd@5.5.0', '--read-only']); + await ready; + try { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names).to.include('dcd_list_devices'); + expect(names).to.not.include('dcd_run_cloud_test'); + } finally { + await client.close(); + } + }); + it('marks the run tool as non-read-only / destructive', async () => { const { client, ready } = connect(); await ready; diff --git a/test/unit/server-json.test.ts b/test/unit/server-json.test.ts new file mode 100644 index 0000000..d3b711e --- /dev/null +++ b/test/unit/server-json.test.ts @@ -0,0 +1,98 @@ +import { expect } from 'chai'; +import { readFileSync } from 'node:fs'; + +/** + * server.json is the MCP registry manifest. Registry clients turn an npm entry + * into `npx @ ` (VS + * Code's McpManagementService does exactly this), and the server is this + * package's *second* bin — so the manifest has to name it through + * `npx --package= dcd-mcp`, or npx runs the default `dcd` bin instead. + * + * The runtime arguments end with a bare `--package`, so the + * `@` the client appends becomes its value, and + * `dcd-mcp` follows as the package argument. The pin therefore lives only in + * `packages[].version`, a bare version string release-please can rewrite. + */ +interface Argument { + name?: string; + type: 'named' | 'positional'; + value?: string; +} + +interface ServerJson { + packages: Array<{ + identifier: string; + packageArguments?: Argument[]; + runtimeArguments?: Argument[]; + runtimeHint?: string; + version: string; + }>; + version: string; +} + +interface ReleasePleaseConfig { + packages: Record< + string, + { 'extra-files'?: Array<{ jsonpath?: string; path: string; type: string }> } + >; +} + +const readJson = (file: string): T => + JSON.parse(readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8')) as T; + +const server = readJson('server.json'); +const npmPackage = server.packages[0]; + +/** A client's rendering of registry arguments into argv (named → flag, value). */ +const render = (args: Argument[] = []): string[] => + args.flatMap((a) => + a.type === 'named' ? [a.name!, ...(a.value ? [a.value] : [])] : [a.value!], + ); + +describe('server.json (MCP registry manifest)', () => { + it('assembles into an npx command that runs the dcd-mcp bin', () => { + const argv = [ + ...render(npmPackage.runtimeArguments), + `${npmPackage.identifier}@${npmPackage.version}`, + ...render(npmPackage.packageArguments), + ]; + + expect(npmPackage.runtimeHint).to.equal('npx'); + expect(argv).to.deep.equal([ + '-y', + '--package', + `@devicecloud.dev/dcd@${npmPackage.version}`, + 'dcd-mcp', + ]); + + const { bin } = readJson<{ bin: Record }>('package.json'); + expect(bin).to.have.property('dcd-mcp'); + }); + + it('pins the last stable release, and only in the bare version fields', () => { + const { '.': stable } = readJson>( + '.release-please-manifest.json', + ); + + expect(server.version).to.equal(stable); + expect(npmPackage.version).to.equal(stable); + + // A version embedded in an argument can't be kept current: release-please's + // json updater replaces the whole matched value with the bare version. + const argv = [ + ...render(npmPackage.runtimeArguments), + ...render(npmPackage.packageArguments), + ]; + expect(argv.filter((a) => /@\d/.test(a))).to.deep.equal([]); + }); + + it('only lets release-please rewrite bare version fields in server.json', () => { + const config = readJson('release-please-config.json'); + const serverJsonPaths = Object.values(config.packages) + .flatMap((pkg) => pkg['extra-files'] ?? []) + .filter((file) => file.path === 'server.json') + .map((file) => file.jsonpath); + + expect(serverJsonPaths).to.have.members(['$.version', '$.packages[*].version']); + }); +});