diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 3f0740f..15d69da 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -37,6 +37,9 @@ jobs: with: fetch-depth: 1 + - name: Record start time + run: echo "REVIEW_STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" + - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 @@ -50,12 +53,52 @@ jobs: # runs and is discarded (the log doesn't even show it unless # show_full_output is enabled). prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }} --comment' - # Explicitly allow the posting tools (mirrors the plugin command's - # allowed-tools frontmatter). In headless mode the frontmatter grant - # doesn't reach the inline-comment MCP tool: PR #9's review hit 46 - # permission denials and posted nothing despite finding issues. - claude_args: | - --model claude-opus-5-5 --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)" + show_full_output: true + # Explicitly allow the posting and reading tools. In headless mode the + # plugin's allowed-tools frontmatter doesn't reach them: PR #9's review + # hit 46 permission denials and posted nothing despite finding issues. + # + # The appended prompt keeps the review in the foreground. Headless runs + # often backgrounded the reviewer subagents and ended the turn to + # "wait" for them, which ends the session: PR #223's review stopped + # after 10 turns and posted nothing. Same fix as dcd's workflow. + claude_args: '--model claude-opus-5-5 --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Read,Glob,Grep,WebFetch,Bash(gh:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*)" --append-system-prompt "You are running non-interactively. Never run subagents in the background and never schedule wakeups: ending your turn ends the session. Wait for every reviewer to finish, then post the review comment before you stop. If you stop early because the PR is trivial or needs no review, still post one short PR comment that says so and why. Do not post anything if you stop because you already commented on this PR."' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options + # The action exits green even when Claude ends its turn before posting + # anything. Fail loudly instead of leaving a silent green check. + # + # The code-review plugin also skips, on purpose, any PR Claude has already + # commented on. So a push after the first review posts nothing, and that + # is not a failure. Only fail when Claude has never posted on the PR. + # + # The plugin also stops early on PRs it judges trivial. The prompt above + # tells Claude to post a one-line note in that case, so a deliberate skip + # leaves a comment and only a real failure trips this check. + - name: Check a review comment was posted + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + # Prints the time of every claude[bot] comment, inline comment and review. + claude_posts() { + gh api "repos/$REPO/issues/$PR/comments" --paginate \ + --jq '.[] | select(.user.login == "claude[bot]") | .created_at' + gh api "repos/$REPO/pulls/$PR/comments" --paginate \ + --jq '.[] | select(.user.login == "claude[bot]") | .created_at' + gh api "repos/$REPO/pulls/$PR/reviews" --paginate \ + --jq '.[] | select(.user.login == "claude[bot]") | .submitted_at' + } + posts=$(claude_posts) + new=$(printf '%s\n' "$posts" | awk -v since="$REVIEW_STARTED_AT" 'NF && $0 >= since' | wc -l | tr -d ' ') + total=$(printf '%s\n' "$posts" | awk 'NF' | wc -l | tr -d ' ') + if [ "$new" -gt 0 ]; then + echo "Claude posted $new comment(s)." + elif [ "$total" -gt 0 ]; then + echo "::notice::No new comment. Claude already reviewed this PR, and the code-review plugin reviews each PR once." + else + echo "::error::Claude finished without posting a review comment on this PR." + exit 1 + fi diff --git a/package.json b/package.json index 1d8784c..462a3a2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "chai": "^6.2.2", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-unicorn": "^76.0.0", + "eslint-plugin-unicorn": "^77.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 78f40e2..84e357c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,10 +45,10 @@ importers: version: 1.8.1 '@modelcontextprotocol/sdk': specifier: ^1.31.0 - version: 1.32.0(zod@4.6.5) + version: 1.32.1(zod@4.6.5) '@supabase/supabase-js': specifier: ^2.108.2 - version: 2.117.2 + version: 2.117.3 bplist-parser: specifier: ^0.5.0 version: 0.5.0 @@ -60,7 +60,7 @@ importers: version: 0.2.2 js-yaml: specifier: ^5.2.2 - version: 5.4.2 + version: 5.4.3 node-stream-zip: specifier: ^1.15.0 version: 1.16.0 @@ -94,7 +94,7 @@ importers: version: 10.0.10 '@types/node': specifier: ^26.0.0 - version: 26.6.4 + version: 26.6.5 '@types/yazl': specifier: ^3.3.1 version: 3.3.1 @@ -108,8 +108,8 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.12.0) eslint-plugin-unicorn: - specifier: ^76.0.0 - version: 76.0.0(eslint@10.12.0) + specifier: ^77.0.0 + version: 77.0.0(eslint@10.12.0) husky: specifier: ^9.1.7 version: 9.1.7 @@ -118,7 +118,7 @@ importers: version: 12.0.3 prettier: specifier: ^3.8.4 - version: 3.9.9 + version: 3.9.10 shx: specifier: ^0.4.0 version: 0.4.0 @@ -130,7 +130,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.61.1 - version: 8.71.0(eslint@10.12.0)(typescript@6.0.3) + version: 8.71.1(eslint@10.12.0)(typescript@6.0.3) packages: @@ -347,8 +347,8 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@hono/node-server@2.1.1': - resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + '@hono/node-server@2.1.4': + resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} engines: {node: '>=20'} peerDependencies: hono: 4.13.7 @@ -386,8 +386,8 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@modelcontextprotocol/sdk@1.32.0': - resolution: {integrity: sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -408,31 +408,31 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@supabase/auth-js@2.117.2': - resolution: {integrity: sha512-Z3WnGwrphYZubrLbxp5Iv0tLA9A5GhvKzJ/ZXglxqwd2QEH2R4dXRltjVJ8sIn4xEW2BSFIGT0yALVJIgZeDYw==} + '@supabase/auth-js@2.117.3': + resolution: {integrity: sha512-XOZV+jllcU2Hzij8SwgM78zl0lOEr6BTjzi2MYkBb39nOAQzb5AelGpMUvVj7lwGSB11JmpAs/FEQroAyFzWEA==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.117.2': - resolution: {integrity: sha512-6DT4ZIjmZxa9ANKaBIrjc86AyKrX8M376ynvNeOBBVad3eP7x3UHqWfDR2ynFkHGzCuOpr1A+kObmnahxuwuog==} + '@supabase/functions-js@2.117.3': + resolution: {integrity: sha512-6GuizbP4N5xWaxEVUsI+UQpJdmMmn0t3sYNpObo9gCAldvM22gFjnYzRVmkHxp5ylU21azEYmiWVwk9iDgCMkQ==} engines: {node: '>=22.0.0'} '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.117.2': - resolution: {integrity: sha512-V1Qhn+M8xzJqCasOxHZ2KG19Fj39PxU7weEAmOK65/KTsWCRXboB4hcQwFvxRvM7ZikeP9IZWNvQkfjvSxzYFw==} + '@supabase/postgrest-js@2.117.3': + resolution: {integrity: sha512-NpyTgullu6tw11ojYKTdZg9HannZ4mu4FUlhc9V5zDXOVllUJzEmuyLM+WWoYF0BurfrRvarimpm3SR8P0Gzfw==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.117.2': - resolution: {integrity: sha512-lYXSAIg3eAKA58riUED6Vb+TCzF8jtx18uOoiCaVJ7ZMre6FWBpwB1MPeW+B6vykJT/hPdiuqwcSB7ILMS21ew==} + '@supabase/realtime-js@2.117.3': + resolution: {integrity: sha512-4W4ISbreRygNXbhwqxeqyuRul8RPx+SF+Hp2IT8wctebQEHSVCULfKatiRiEhmasfoXvYm2AHV4XFsOxhoousA==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.117.2': - resolution: {integrity: sha512-8gAJoVaxZa/War2kFRfJMxGk4M190Q7lJ70BofwGlRxU/u9pIodd6XyTvmXkxOKXWuIYtkDaYPZrIAUt1T/7FQ==} + '@supabase/storage-js@2.117.3': + resolution: {integrity: sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.117.2': - resolution: {integrity: sha512-eSG2VKnHR+Clp1PmidZ1/weJ8PJwoybjva3L2GgKqFG4YDS1Iqmc61psKGZP5xw6OMT2O7ZorPR42PY6q1BOXg==} + '@supabase/supabase-js@2.117.3': + resolution: {integrity: sha512-K+f8PimXDunPWQ63CKRXaF5pbVPJxmfw5ryAnh0s9S9PkwcuqBjiSpIaqpF7KLDljeYwXIL85ST8QxaO0+XuvA==} engines: {node: '>=22.0.0'} peerDependencies: '@opentelemetry/api': '>=1.0.0' @@ -461,69 +461,69 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.6.4': - resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} - '@typescript-eslint/eslint-plugin@8.71.0': - resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.71.0 + '@typescript-eslint/parser': ^8.71.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.0': - resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.0': - resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.71.0': - resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.0': - resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.0': - resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.71.0': - resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.0': - resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.0': - resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.71.0': - resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.12': @@ -569,8 +569,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.25: - resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + baseline-browser-mapping@2.11.28: + resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} engines: {node: '>=6.0.0'} hasBin: true @@ -593,8 +593,8 @@ packages: browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - browserslist@4.29.0: - resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -624,8 +624,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001810: - resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + caniuse-lite@1.0.30001815: + resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} @@ -656,8 +656,8 @@ packages: combine-errors@3.0.3: resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} content-type@1.0.5: @@ -726,8 +726,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.433: - resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} + electron-to-chromium@1.5.452: + resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -774,8 +774,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-unicorn@76.0.0: - resolution: {integrity: sha512-3ywJrCMHKYhcBP5yFzGaOOX3dY7+iR3hvpuoS/0EnhRFgVWLejly+t4fOdJvWQxJdHZl1C+QBW7hybXPDld4Pw==} + eslint-plugin-unicorn@77.0.0: + resolution: {integrity: sha512-aayznMCKTXlPAsoyUjRok862sz3BJAfxpQX/t79AT/uVQTmWsCx3FXfXo+9hfhL8aR2mMZQYyXoDZo3/ly6lJw==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -838,14 +838,14 @@ packages: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} - express-rate-limit@8.7.0: - resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} fast-deep-equal@3.1.3: @@ -950,8 +950,8 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - globals@17.12.0: - resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + globals@17.13.0: + resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} engines: {node: '>=18'} gopd@1.2.0: @@ -1031,8 +1031,8 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} - ip-address@10.7.2: - resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1087,8 +1087,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@5.4.2: - resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + js-yaml@5.4.3: + resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} hasBin: true jsesc@3.1.0: @@ -1215,8 +1215,8 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - node-releases@2.0.56: - resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + node-releases@2.0.58: + resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} node-stream-zip@1.16.0: @@ -1311,8 +1311,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.9: - resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} engines: {node: '>=14'} hasBin: true @@ -1364,8 +1364,8 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - regjsparser@0.13.3: - resolution: {integrity: sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==} + regjsparser@0.13.4: + resolution: {integrity: sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==} hasBin: true require-from-string@2.0.2: @@ -1544,8 +1544,8 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typescript-eslint@8.71.0: - resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1563,8 +1563,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.3.3: - resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -1769,7 +1769,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hono/node-server@2.1.1(hono@4.13.7)': + '@hono/node-server@2.1.4(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -1801,9 +1801,9 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@modelcontextprotocol/sdk@1.32.0(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)': dependencies: - '@hono/node-server': 2.1.1(hono@4.13.7) + '@hono/node-server': 2.1.4(hono@4.13.7) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -1811,8 +1811,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 - express: 5.2.1 - express-rate-limit: 8.7.0(express@5.2.1) + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) hono: 4.13.7 jose: 6.2.12 json-schema-typed: 8.0.2 @@ -1835,37 +1835,37 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@supabase/auth-js@2.117.2': + '@supabase/auth-js@2.117.3': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.117.2': + '@supabase/functions-js@2.117.3': dependencies: tslib: 2.8.1 '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.117.2': + '@supabase/postgrest-js@2.117.3': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.117.2': + '@supabase/realtime-js@2.117.3': dependencies: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.117.2': + '@supabase/storage-js@2.117.3': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.117.2': + '@supabase/supabase-js@2.117.3': dependencies: - '@supabase/auth-js': 2.117.2 - '@supabase/functions-js': 2.117.2 - '@supabase/postgrest-js': 2.117.2 - '@supabase/realtime-js': 2.117.2 - '@supabase/storage-js': 2.117.2 + '@supabase/auth-js': 2.117.3 + '@supabase/functions-js': 2.117.3 + '@supabase/postgrest-js': 2.117.3 + '@supabase/realtime-js': 2.117.3 + '@supabase/storage-js': 2.117.3 '@types/chai@5.2.3': dependencies: @@ -1884,22 +1884,22 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/node@26.6.4': + '@types/node@26.6.5': dependencies: undici-types: 8.9.0 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.6.4 + '@types/node': 26.6.5 - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.0(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/type-utils': 8.71.0(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 eslint: 10.12.0 ignore: 7.0.12 natural-compare: 1.4.0 @@ -1908,41 +1908,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.0(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@8.1.1) eslint: 10.12.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) - '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.71.0': + '@typescript-eslint/scope-manager@8.71.1': dependencies: - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 - '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.71.0(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) eslint: 10.12.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -1950,14 +1950,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/types@8.71.1': {} - '@typescript-eslint/typescript-estree@8.71.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.71.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/project-service': 8.71.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.3 semver: 7.8.5 @@ -1967,20 +1967,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.0(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.1(eslint@10.12.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) eslint: 10.12.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.71.0': + '@typescript-eslint/visitor-keys@8.71.1': dependencies: - '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 '@xmldom/xmldom@0.9.12': {} @@ -2020,7 +2020,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.25: {} + baseline-browser-mapping@2.11.28: {} body-parser@2.3.0: dependencies: @@ -2048,13 +2048,13 @@ snapshots: browser-stdout@1.3.1: {} - browserslist@4.29.0: + browserslist@4.29.3: dependencies: - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.433 - node-releases: 2.0.56 - update-browserslist-db: 1.3.3(browserslist@4.29.0) + baseline-browser-mapping: 2.11.28 + caniuse-lite: 1.0.30001815 + electron-to-chromium: 1.5.452 + node-releases: 2.0.58 + update-browserslist-db: 1.3.4(browserslist@4.29.3) buffer-crc32@1.0.0: {} @@ -2082,7 +2082,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001810: {} + caniuse-lite@1.0.30001815: {} chai@6.3.0: {} @@ -2105,7 +2105,7 @@ snapshots: custom-error-instance: 2.1.1 lodash.uniqby: 4.5.0 - content-disposition@1.1.0: {} + content-disposition@2.0.1: {} content-type@1.0.5: {} @@ -2117,7 +2117,7 @@ snapshots: core-js-compat@3.50.0: dependencies: - browserslist: 4.29.0 + browserslist: 4.29.3 cors@2.8.6: dependencies: @@ -2162,7 +2162,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.433: {} + electron-to-chromium@1.5.452: {} encodeurl@2.0.0: {} @@ -2219,11 +2219,11 @@ snapshots: dependencies: eslint: 10.12.0 - eslint-plugin-unicorn@76.0.0(eslint@10.12.0): + eslint-plugin-unicorn@77.0.0(eslint@10.12.0): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) '@eslint/css-tree': 4.1.1 - browserslist: 4.29.0 + browserslist: 4.29.3 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.50.0 @@ -2231,13 +2231,13 @@ snapshots: entities: 8.1.0 eslint: 10.12.0 find-up-simple: 1.0.1 - globals: 17.12.0 + globals: 17.13.0 identifier-regex: 1.1.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 pluralize: 8.0.0 quote-js-string: 0.1.0 - regjsparser: 0.13.3 + regjsparser: 0.13.4 reserved-identifiers: 1.2.0 semver: 7.8.5 strip-indent: 4.1.1 @@ -2325,20 +2325,20 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 - express-rate-limit@8.7.0(express@5.2.1): + express-rate-limit@8.7.1(express@5.3.0): dependencies: debug: 4.4.3(supports-color@8.1.1) - express: 5.2.1 - ip-address: 10.7.2 + express: 5.3.0 + ip-address: 10.7.3 transitivePeerDependencies: - supports-color - express@5.2.1: + express@5.3.0: dependencies: accepts: 2.0.0 body-parser: 2.3.0 - content-disposition: 1.1.0 - content-type: 1.0.5 + content-disposition: 2.0.1 + content-type: 2.1.0 cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3(supports-color@8.1.1) @@ -2479,7 +2479,7 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - globals@17.12.0: {} + globals@17.13.0: {} gopd@1.2.0: {} @@ -2535,7 +2535,7 @@ snapshots: interpret@1.4.0: {} - ip-address@10.7.2: {} + ip-address@10.7.3: {} ipaddr.js@1.9.1: {} @@ -2571,7 +2571,7 @@ snapshots: js-base64@3.7.8: {} - js-yaml@5.4.2: + js-yaml@5.4.3: dependencies: argparse: 2.0.1 @@ -2673,7 +2673,7 @@ snapshots: glob: 13.0.6 is-path-inside: 4.0.0 is-unicode-supported: 0.1.0 - js-yaml: 5.4.2 + js-yaml: 5.4.3 minimatch: 10.2.3 ms: 2.1.3 picocolors: 1.1.1 @@ -2692,7 +2692,7 @@ snapshots: nice-try@1.0.5: {} - node-releases@2.0.56: {} + node-releases@2.0.58: {} node-stream-zip@1.16.0: {} @@ -2765,7 +2765,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.9: {} + prettier@3.9.10: {} proper-lockfile@4.1.2: dependencies: @@ -2815,7 +2815,7 @@ snapshots: dependencies: resolve: 1.22.12 - regjsparser@0.13.3: + regjsparser@0.13.4: dependencies: jsesc: 3.1.0 @@ -3008,12 +3008,12 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript-eslint@8.71.0(eslint@10.12.0)(typescript@6.0.3): + typescript-eslint@8.71.1(eslint@10.12.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.71.0(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.71.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) eslint: 10.12.0 typescript: 6.0.3 transitivePeerDependencies: @@ -3025,9 +3025,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.3.3(browserslist@4.29.0): + update-browserslist-db@1.3.4(browserslist@4.29.3): dependencies: - browserslist: 4.29.0 + browserslist: 4.29.3 escalade: 3.2.0 picocolors: 1.1.1 diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 56d33f7..8dabab3 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -13,6 +13,10 @@ import { } from '../methods.js'; import { DeviceValidationService } from '../services/device-validation.service.js'; import { plan } from '../services/execution-plan.service.js'; +import { + isSubmitEndpointMissing, + submitWithRetry, +} from '../services/flow-submit.service.js'; import { buildTestMetadataMap, computeCommonRoot, @@ -28,7 +32,7 @@ import { telemetry } from '../services/telemetry.service.js'; import { TestSubmissionService } from '../services/test-submission.service.js'; import { VersionService } from '../services/version.service.js'; import { isIosMatrixConfig } from '../types/domain/device.types.js'; -import { resolveAuth } from '../utils/auth.js'; +import { refreshAuth, resolveAuth } from '../utils/auth.js'; import { assertMatrixSupported, matrixIsIos, @@ -80,6 +84,7 @@ const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const; const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'] as const; const ORIENTATION_OPTIONS = ['0', '90'] as const; const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'] as const; +const PRIORITY_OPTIONS = ['low', 'normal', 'high'] as const; const RENDER_ENGINE_OPTIONS = ['lavapipe', 'swiftshader'] as const; /** @@ -229,6 +234,12 @@ export const cloudCommand = defineCommand({ ORIENTATION_OPTIONS, 'orientation', ); + const priority = + validateEnum( + args.priority as string | undefined, + PRIORITY_OPTIONS, + 'priority', + ) ?? 'normal'; let quiet = Boolean(args.quiet); const report = validateEnum( args.report as string | undefined, @@ -858,6 +869,7 @@ export const cloudCommand = defineCommand({ metadata: mergedMetadata, name, orientation, + priority, raw: [], report, retry, @@ -921,9 +933,27 @@ export const cloudCommand = defineCommand({ // 404s (route undefined), some proxies 405 (path/method not allowed); in // either case fall back to the legacy multipart POST /uploads/flow, which // is byte-identical bar the storage reference. - let response: Awaited>; - try { - const storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug }); + const submitMultipart = (status: number) => { + if (debug) { + out( + `[DEBUG] Client-direct flow upload unavailable (HTTP ${status}); falling back to multipart ${apiUrl}/uploads/flow`, + ); + } + + const testFormData = testSubmissionService.buildFormData(fields, buffer); + return ApiGateway.uploadFlow(apiUrl, auth, testFormData); + }; + + const submitFlows = async (): Promise< + Awaited> + > => { + let storageRef: Awaited>; + try { + storageRef = await uploadFlowZip({ apiUrl, auth, buffer, debug }); + } catch (error) { + if (!isSubmitEndpointMissing(error)) throw error; + return submitMultipart((error as ApiError).status); + } if (debug) { out( @@ -932,27 +962,29 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] Submitting flow test to ${apiUrl}/uploads/submitFlowTest`); } - response = await ApiGateway.submitFlowTest(apiUrl, auth, { - ...fields, - ...storageRef, + // The same body on every attempt: its upload id is what lets the API + // answer a repeat with the run it already created (dcd#1617 follow-up). + const submitBody = { ...fields, ...storageRef }; + // The retries can outlast a `dcd login` access token, so each attempt + // re-checks it, as the poll loop does. + let submitAuth = auth; + const submitted = await submitWithRetry({ + fallback: (error) => submitMultipart(error.status), + // Recorded before each attempt, so `command failed` carries it too. + onAttempt: (attempt) => telemetry.recordSubmitAttempts(attempt), + onRetry: ({ attempt, attempts, delayMs, reason }) => + warnOut( + `Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`, + ), + async submit() { + submitAuth = await refreshAuth(submitAuth); + return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody); + }, }); - } catch (error) { - if ( - error instanceof ApiError && - (error.status === 404 || error.status === 405) - ) { - if (debug) { - out( - `[DEBUG] Client-direct flow upload unavailable (HTTP ${error.status}); falling back to multipart ${apiUrl}/uploads/flow`, - ); - } + return submitted.response; + }; - const testFormData = testSubmissionService.buildFormData(fields, buffer); - response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData); - } else { - throw error; - } - } + const response = await submitFlows(); const { message, results } = response; diff --git a/src/config/flags/execution.flags.ts b/src/config/flags/execution.flags.ts index 0819fa9..3018962 100644 --- a/src/config/flags/execution.flags.ts +++ b/src/config/flags/execution.flags.ts @@ -41,6 +41,12 @@ export const executionFlags = { description: 'Maestro version to run your flow against. Use "latest" for the most recent version. See https://docs.devicecloud.dev/configuration/maestro-versions for supported versions.', }, + priority: { + type: 'string', + default: 'normal', + description: + 'Run priority within your organization (options: low, normal, high). Reorders only your own queued runs across your concurrency pool, for every flow in this upload; it never moves them ahead of other customers. Runs queued longer than 6 hours are promoted first, so low runs are never starved. Retries keep the same priority.', + }, retry: { type: 'string', description: diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 37fd6da..d08eb1d 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -44,6 +44,20 @@ async function parseJsonResponse(res: Response, operation: string): Promise = { ...auth.headers }; + if (cache?.body && cache.etag) { + headers['if-none-match'] = cache.etag; + // Per the Fetch spec, undici appends `Cache-Control: no-cache` to any + // request that carries a conditional header and no Cache-Control of + // its own, and Express's `fresh` never answers 304 to a no-cache + // request. So every poll would get a full 200. max-age=0 still asks + // for revalidation, and keeps undici from adding no-cache. + headers['cache-control'] = 'max-age=0'; + } + + const res = await fetch(url, { headers }); + + // Before the `!res.ok` check, since a 304 is not `ok`. + if (res.status === 304) { + if (cache?.body) { + return cache.body; + } + + // We sent no If-None-Match, so there is nothing to reuse. Start over + // with an unconditional request on the next poll. + if (cache) { + delete cache.body; + delete cache.etag; + } + + throw new Error( + 'Failed to get results: API returned 304 Not Modified to an unconditional request', + ); + } + if (!res.ok) { await this.handleApiError(res, 'Failed to get results'); } - return await parseJsonResponse< - paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json'] - >(res, 'Failed to get results'); + const body = await parseJsonResponse(res, 'Failed to get results'); + + if (cache) { + // Only pin a real result set. The API reports some errors as a 200 + // with a `{ statusCode, message }` envelope, and an empty set is + // rejected by the poll loop; caching either would replay it on 304. + const etag = res.headers.get('etag'); + if (etag && Array.isArray(body.results) && body.results.length > 0) { + cache.body = body; + cache.etag = etag; + } else { + delete cache.body; + delete cache.etag; + } + } + + return body; } catch (error) { if (error instanceof TypeError && error.message === 'fetch failed') { - throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}`); + throw this.enhanceFetchError(error, url); } throw error; @@ -687,6 +766,7 @@ export const ApiGateway = { ...auth.headers, }, method: 'POST', + signal: AbortSignal.timeout(this.submitAttemptTimeoutMs), }); if (!res.ok) { await this.handleApiError(res, 'Failed to submit test flows'); @@ -702,10 +782,29 @@ export const ApiGateway = { throw this.enhanceFetchError(error, `${baseUrl}/uploads/submitFlowTest`); } + if (error instanceof Error && error.name === 'TimeoutError') { + const timeout = new Error( + `Failed to submit test flows: no response from the API within ${this.submitAttemptTimeoutMs / 1000}s`, + { cause: error }, + ); + timeout.name = 'SubmitTimeoutError'; + throw timeout; + } + throw error; } }, + /** + * How long one submit attempt may take (dcd#1617 follow-up). A submit + * normally answers in a second or two; one held by a Supabase stall is + * abandoned and re-sent by `submitWithRetry`, which is safe because the API + * answers a repeat with the run it already created. Well under the 300s at + * which Railway's edge would cut it with a 502. Settable so tests can + * shorten it. + */ + submitAttemptTimeoutMs: 90_000, + /** * Dry-run cost + cell-count estimate for a (possibly device-matrix) * submission. Runs the same resolve → validate → fan-out → price core as the diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 59bd092..c57137f 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -3,8 +3,12 @@ import * as path from 'node:path'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { ApiError, ApiGateway } from '../../gateways/api-gateway.js'; +import { ApiGateway } from '../../gateways/api-gateway.js'; import { plan } from '../../services/execution-plan.service.js'; +import { + isSubmitEndpointMissing, + submitWithRetry, +} from '../../services/flow-submit.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'; @@ -83,6 +87,12 @@ export function registerRunCloudTest(server: McpServer): void { .describe( 'Runner type (default "default"). gpu1 is deprecated and will be removed on 9 November 2026; prefer cpu1.', ), + priority: z + .enum(['low', 'normal', 'high']) + .optional() + .describe( + 'Run priority within the organization (default "normal"). Reorders only the queued runs of this organization, for every flow in this upload; it never moves them ahead of other customers. Runs queued over 6 hours are promoted first, so low runs are never starved.', + ), configFile: z.string().optional().describe('Path to a workspace config.yaml'), ignoreShaCheck: z .boolean() @@ -263,6 +273,7 @@ export function registerRunCloudTest(server: McpServer): void { iOSDevice: args.iosDevice, iOSVersion: args.iosVersion, name: args.name, + priority: args.priority, runnerType: args.runnerType ?? 'default', maestroVersion: resolvedMaestroVersion, retry: args.retry, @@ -275,27 +286,48 @@ export function registerRunCloudTest(server: McpServer): void { // referencing it. Older API deployments lack these endpoints (404/405); // fall back to the legacy multipart POST /uploads/flow. Mirrors // `dcd cloud`. - let response: Awaited>; - try { - const storageRef = await uploadFlowZip({ apiUrl, auth, buffer }); - response = await ApiGateway.submitFlowTest(apiUrl, auth, { - ...fields, - ...storageRef, - }); - } catch (error) { - if ( - error instanceof ApiError && - (error.status === 404 || error.status === 405) - ) { - const testFormData = testSubmissionService.buildFormData( - fields, - buffer, - ); - response = await ApiGateway.uploadFlow(apiUrl, auth, testFormData); - } else { - throw error; + const submitMultipart = () => + ApiGateway.uploadFlow( + apiUrl, + auth, + testSubmissionService.buildFormData(fields, buffer), + ); + const submitFlows = async (): Promise< + Awaited> + > => { + let storageRef: Awaited>; + try { + storageRef = await uploadFlowZip({ apiUrl, auth, buffer }); + } catch (error) { + if (!isSubmitEndpointMissing(error)) throw error; + return submitMultipart(); } - } + + // Re-sent while its outcome is unknown, always with the same body; + // see submitWithRetry (dcd#1617 follow-up). + const submitBody = { ...fields, ...storageRef }; + // The retries can outlast a `dcd login` access token, so each + // attempt re-checks it, as the wait loop below does. + let submitAuth = auth; + const submitted = await submitWithRetry({ + fallback: submitMultipart, + // Recorded before each attempt, so a failed tool call carries it. + onAttempt: (attempt) => { + telemetryExtra.submitAttempts = attempt; + }, + onRetry: ({ attempt, attempts, delayMs, reason }) => + logStderr( + `Could not confirm the test submission (${reason}); sending it again in ${delayMs / 1000}s (attempt ${attempt}/${attempts}). It will not run twice.`, + ), + async submit() { + submitAuth = await refreshAuth(submitAuth); + return ApiGateway.submitFlowTest(apiUrl, submitAuth, submitBody); + }, + }); + return submitted.response; + }; + + const response = await submitFlows(); const { message, results } = response; if (!results?.length) { diff --git a/src/services/device-validation.service.ts b/src/services/device-validation.service.ts index b3f4a98..482d97e 100644 --- a/src/services/device-validation.service.ts +++ b/src/services/device-validation.service.ts @@ -80,6 +80,22 @@ export class DeviceValidationService { : compatibilityData.android; const argNames = options.argNames ?? CLI_ARG_NAMES; + // With Google Play the API does not give an omitted half the global + // default (no longer a Play cell): it picks one that makes the pair a Play + // cell, and only rejects when none exists (resolveAndroidTarget in + // compatibility.service.ts). Fill it the same way so a pair the API will + // run is not refused here. + let device = androidDevice; + let apiLevel = androidApiLevel; + if (googlePlay && device && !apiLevel) { + apiLevel = versionsFor(lookup, device)[0]; + } else if (googlePlay && apiLevel && !device) { + const level = apiLevel; + device = offeredDevices(lookup).find((d) => + versionsFor(lookup, d).includes(level), + ); + } + this.validateDevice({ debugLines: (deviceID, version, supportedVersions) => [ `[DEBUG] Android device: ${deviceID}`, @@ -88,11 +104,11 @@ export class DeviceValidationService { `[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) + // request that omits either half (execution-planner.service.ts) // and then rejects an incompatible pair. defaultDevice: 'pixel-7', - defaultVersion: '34', - device: androidDevice, + defaultVersion: '36', + device, deviceArg: argNames.androidDevice, deviceKind: googlePlay ? 'Google Play' : 'Android', formatVersion: (version) => `API level ${version}`, @@ -109,7 +125,7 @@ export class DeviceValidationService { `${deviceID} ${ googlePlay ? '(Play Store) ' : '' }only supports these Android API levels: ${supportedVersions.join(', ')}`, - version: androidApiLevel, + version: apiLevel, versionArg: argNames.androidApiLevel, versionName: 'Android API level', }); diff --git a/src/services/flow-submit.service.ts b/src/services/flow-submit.service.ts new file mode 100644 index 0000000..8c81f7f --- /dev/null +++ b/src/services/flow-submit.service.ts @@ -0,0 +1,150 @@ +import { ApiError } from '../gateways/api-gateway.js'; +import { isDefinitiveAuthFailure } from '../utils/auth.js'; + +/** + * Submitting a flow test when the response may be lost (dcd#1617 follow-up). + * + * Supabase's ingress sometimes holds a request for minutes before answering, + * and Railway's edge cuts a request that is silent for 300s with a 502. Either + * way the CLI cannot tell whether its run was created. The API makes that safe + * to find out: it answers a repeat of the same submission (same body, same + * upload id from getFlowUploadUrl) with the run it already created, or a 409 + * while that run is still being created. It never creates a second run or a + * second charge. So a submit whose outcome is unknown is simply sent again. + */ + +/** + * Waits before each re-send: 2s, then up to 30s apart, about three minutes in + * all. That is long enough for a write held in a stall to land, after which the + * repeat is answered with the run. + */ +export const SUBMIT_RETRY_DELAYS_MS: readonly number[] = [ + 2000, 5000, 10_000, 20_000, 30_000, 30_000, 30_000, 30_000, +]; + +/** + * No attempt starts more than this long after the first. Each attempt can take + * up to its 90s timeout, so without a cap every attempt timing out would hold + * the command for about 16 minutes; with it, the worst case is about 6.5. + */ +export const SUBMIT_RETRY_BUDGET_MS = 5 * 60_000; + +/** + * Statuses that leave the outcome unknown (502/503/504 from the edge or the + * API), say the API could not finish (500), or say the first attempt is still + * creating the run (409 SUBMISSION_IN_PROGRESS). Re-sending is only safe + * because the API dedupes on the upload id. + */ +const RETRYABLE_STATUSES = new Set([409, 500, 502, 503, 504]); + +/** + * Whether a failed attempt is worth sending again. Anything that is not an API + * answer is a transport failure: a network error, the attempt timeout, or a + * body that died mid-read after a 2xx. In every case the run may already exist, + * which a repeat finds out. The npm build throws undici's errors and the bun + * binaries their own; neither is an ApiError, so both retry. + */ +export function isRetryableSubmitError(error: unknown): boolean { + if (error instanceof ApiError) return RETRYABLE_STATUSES.has(error.status); + // The session can't be refreshed (refused, or logged out): only `dcd login` + // fixes that, so another attempt would fail the same way. + if (isDefinitiveAuthFailure(error)) return false; + return true; +} + +/** A one-line reason for the retry notice. Network errors carry a long help text. */ +export function describeSubmitError(error: unknown): string { + if (error instanceof ApiError) return `HTTP ${error.status}`; + if (error instanceof Error) { + if (error.name === 'SubmitTimeoutError') return 'no response in time'; + const code = (error.cause as { code?: unknown } | undefined)?.code; + if (typeof code === 'string') return code; + return error.name === 'NetworkError' ? 'network error' : error.name; + } + + return String(error); +} + +/** The API predates the client-direct submit: a real API 404s, some proxies 405. */ +export function isSubmitEndpointMissing(error: unknown): boolean { + return ( + error instanceof ApiError && (error.status === 404 || error.status === 405) + ); +} + +export interface SubmitRetryNotice { + /** The attempt about to be sent, counting from 1. */ + attempt: number; + attempts: number; + delayMs: number; + reason: string; +} + +/** + * Send a submission, sending it again while its outcome is unknown. + * + * `fallback` is the legacy multipart submit for an API that predates this + * endpoint. It is used only when the *first* attempt says the endpoint is + * missing. A 404 on a later attempt comes from an API that took the earlier + * attempt, so a multipart submit then would create a second run. + * + * Resolves with the response and how many attempts it took. Throws the last + * error when it is not retryable or the attempts run out. + */ +export async function submitWithRetry(opts: { + budgetMs?: number; + delaysMs?: readonly number[]; + fallback: (error: ApiError) => Promise; + now?: () => number; + /** + * Called before each attempt with its number, counting from 1, so a caller + * can record how many were made whether the submit succeeds or not. + */ + onAttempt?: (attempt: number) => void; + onRetry?: (notice: SubmitRetryNotice) => void; + sleep?: (ms: number) => Promise; + submit: () => Promise; +}): Promise<{ attempts: number; response: T }> { + const delaysMs = opts.delaysMs ?? SUBMIT_RETRY_DELAYS_MS; + const budgetMs = opts.budgetMs ?? SUBMIT_RETRY_BUDGET_MS; + const now = opts.now ?? Date.now; + const startedAt = now(); + const sleep = + opts.sleep ?? + ((ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + })); + const attempts = delaysMs.length + 1; + + for (let attempt = 1; ; attempt++) { + opts.onAttempt?.(attempt); + try { + return { attempts: attempt, response: await opts.submit() }; + } catch (error) { + if (attempt === 1 && isSubmitEndpointMissing(error)) { + return { + attempts: attempt, + response: await opts.fallback(error as ApiError), + }; + } + + const delayMs = delaysMs[attempt - 1]; + if ( + delayMs === undefined || + !isRetryableSubmitError(error) || + now() - startedAt + delayMs > budgetMs + ) { + throw error; + } + + opts.onRetry?.({ + attempt: attempt + 1, + attempts, + delayMs, + reason: describeSubmitError(error), + }); + await sleep(delayMs); + } + } +} diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 494b1a8..d411451 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; -import { ApiGateway } from '../gateways/api-gateway.js'; +import { ApiGateway, type ResultsPollCache } from '../gateways/api-gateway.js'; import { RealtimeResultsGateway, type RealtimeResultsSubscription, @@ -215,6 +215,10 @@ export class ResultsPollingService { let sequentialPollFailures = 0; + // The last result set and its ETag, so a poll that finds nothing changed + // gets an empty 304 instead of the whole set again. + const resultsCache: ResultsPollCache = {}; + const pollIntervalMs = auth.mode === 'bearer' ? this.BEARER_POLL_INTERVAL_MS @@ -324,7 +328,14 @@ export class ResultsPollingService { renderStatus(); auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger); - const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger); + const updatedResults = await this.fetchAndLogResults( + apiUrl, + auth, + uploadId, + resultsCache, + debug, + logger, + ); const { summary } = this.calculateStatusSummary(updatedResults); if (!json) { @@ -547,6 +558,7 @@ export class ResultsPollingService { * @param apiUrl API base URL * @param auth AuthContext carrying request headers * @param uploadId Upload ID to fetch results for + * @param cache The poll loop's results cache, for ETag revalidation * @param debug Whether debug logging is enabled * @param logger Optional logger function * @returns Promise resolving to test results @@ -555,6 +567,7 @@ export class ResultsPollingService { apiUrl: string, auth: AuthContext, uploadId: string, + cache: ResultsPollCache, debug: boolean, logger?: (message: string) => void, ): Promise { @@ -562,8 +575,11 @@ export class ResultsPollingService { logger(`[DEBUG] Polling for results: ${uploadId}`); } - const { results: updatedResults } = - await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId); + // A 304 hands back the cached body itself; a 200 is always a new object. + const cachedBody = cache.body; + const response = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId, cache); + const notModified = cachedBody !== undefined && response === cachedBody; + const { results: updatedResults } = response; // An empty array would otherwise read as "all complete, all passed". if (!updatedResults || updatedResults.length === 0) { @@ -571,7 +587,11 @@ export class ResultsPollingService { } if (debug && logger) { - logger(`[DEBUG] Poll received ${updatedResults.length} results`); + logger( + notModified + ? `[DEBUG] Poll not modified (304), reusing ${updatedResults.length} cached results` + : `[DEBUG] Poll received ${updatedResults.length} results`, + ); for (const result of updatedResults) { logger( `[DEBUG] Result status: ${result.test_file_name} - ${result.status}`, diff --git a/src/services/telemetry.service.ts b/src/services/telemetry.service.ts index 41d0778..6e3df8e 100644 --- a/src/services/telemetry.service.ts +++ b/src/services/telemetry.service.ts @@ -53,6 +53,7 @@ class Telemetry { private config: TelemetryConfig | null = null; private command: string = inferCommandFromArgv(); private encryption: EncryptionTelemetry | null = null; + private submitAttempts: number | undefined; private sessionId: string = randomUUID(); private startedAt: number = Date.now(); private readonly disabled: boolean = !!process.env.DCD_TELEMETRY_DISABLED; @@ -139,11 +140,22 @@ class Telemetry { }; } + /** + * Record how many attempts the flow submit took (dcd#1617 follow-up), sent + * as `submit_attempts` on `command completed` / `command failed`. Anything + * above 1 means a response was lost and the submit was sent again, which is + * what to count when judging how often the API stalls under the CLI. + */ + recordSubmitAttempts(attempts: number) { + this.submitAttempts = attempts; + } + recordCommandSuccess() { this.enqueue('info', 'cli.lifecycle', 'command completed', { duration_ms: Date.now() - this.startedAt, exit_code: 0, ...this.encryptionExtra(), + ...this.submitExtra(), }); } @@ -162,6 +174,7 @@ class Telemetry { error_stack: opts.error instanceof Error ? opts.error.stack : undefined, ...this.encryptionExtra(), + ...this.submitExtra(), }); } @@ -169,6 +182,12 @@ class Telemetry { return this.encryption ? { encrypt: this.encryption } : {}; } + private submitExtra(): { submit_attempts?: number } { + return this.submitAttempts === undefined + ? {} + : { submit_attempts: this.submitAttempts }; + } + private enqueue( level: TelemetryLevel, context: string, diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 2eff7ba..367208b 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -62,6 +62,13 @@ export interface TestSubmissionConfig { metadata?: string[]; name?: string; orientation?: string; + /** + * Where this upload sits among the org's own queued runs. Like + * `cancelPrevious`, sent as its own field rather than inside `config`: it is + * a scheduling directive for the whole upload, not run configuration. Only + * reorders the org's own queue; it never moves the org ahead of others. + */ + priority?: 'low' | 'normal' | 'high'; raw?: unknown; /** * Which emulator software renderer to boot with (Android only). Omitted @@ -118,6 +125,7 @@ export class TestSubmissionService { maestroVersion, deviceLocale, orientation, + priority, retry, continueOnFailure = true, renderEngine, @@ -361,6 +369,12 @@ export class TestSubmissionService { fields.cancelPrevious = 'true'; } + // Same reasoning: 'normal' is the default and is never sent, so the + // default path's wire shape is unchanged and an older API never sees it. + if (priority && priority !== 'normal') { + fields.priority = priority; + } + this.setOptionalFields(fields, { androidApiLevel, androidDevice, diff --git a/src/types/domain/device.types.ts b/src/types/domain/device.types.ts index 8715385..e1ff8c8 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -51,15 +51,12 @@ export enum EiOSVersions { } export enum EAndroidApiLevels { - 'thirty' = '30', 'thirtyFive' = '35', 'thirtyFour' = '34', - 'thirtyOne' = '31', 'thirtySeven' = '37', 'thirtySix' = '36', 'thirtyThree' = '33', 'thirtyTwo' = '32', - 'twentyNine' = '29', } /** diff --git a/test/unit/device-validation.service.test.ts b/test/unit/device-validation.service.test.ts index db5c104..2ed44c4 100644 --- a/test/unit/device-validation.service.test.ts +++ b/test/unit/device-validation.service.test.ts @@ -130,7 +130,7 @@ describe('DeviceValidationService', () => { }); // 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 + // iOS 17, pixel-7 / API 36) 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', () => { @@ -140,14 +140,15 @@ describe('DeviceValidationService', () => { }); 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', + expect(() => android(undefined, 'pixel-12')).to.throw( + 'pixel-12 only supports these Android API levels: 38. No Android API level was given, so the default (36) 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(); + expect(() => android(undefined, 'pixel-10')).to.not.throw(); }); it('names the MCP tool parameters when asked to', () => { @@ -188,4 +189,30 @@ describe('DeviceValidationService', () => { expect(() => android('36', undefined, false, withoutPixel7)).to.not.throw(); }); }); + + // With Google Play the API does not apply the global default to an omitted + // half — pixel-7 / API 36 has no Play image — but picks whichever value + // makes the pair a Play cell, and only rejects when there is none. + describe('Google Play requests that name one half', () => { + const play36 = { + ...compat, + androidPlay: { ...compat.androidPlay, 'pixel-10': ['36'] }, + }; + + it("accepts a device-only request for the device's Play level", () => { + expect(() => android(undefined, 'pixel-7', true)).to.not.throw(); + expect(() => android(undefined, 'pixel-10', true, play36)).to.not.throw(); + }); + + it('accepts a version-only request when any Play device offers it', () => { + expect(() => android('34', undefined, true)).to.not.throw(); + expect(() => android('36', undefined, true, play36)).to.not.throw(); + }); + + it('still rejects a device-only request for a device with no Play image', () => { + expect(() => android(undefined, 'pixel-10', true)).to.throw( + /Android device "pixel-10" is not available with Google Play\. Google Play devices: pixel-7\./, + ); + }); + }); }); diff --git a/test/unit/flow-submit.test.ts b/test/unit/flow-submit.test.ts new file mode 100644 index 0000000..868762a --- /dev/null +++ b/test/unit/flow-submit.test.ts @@ -0,0 +1,318 @@ +import { expect } from 'chai'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { ApiError, ApiGateway } from '../../src/gateways/api-gateway.js'; +import { SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; +import { + isRetryableSubmitError, + SUBMIT_RETRY_BUDGET_MS, + submitWithRetry, +} from '../../src/services/flow-submit.service.js'; +import { CliError } from '../../src/utils/cli.js'; +import type { AuthContext } from '../../src/types/domain/auth.types.js'; + +/** + * Re-sending a flow submit whose outcome is unknown (dcd#1617 follow-up). + * + * The API answers a repeat of the same body (same upload id) with the run it + * already created, so the CLI can send a submit again after a 502, a timeout + * or a dropped connection without creating a second run. These go through the + * real gateway and the real fetch to a local server, so the network-error and + * timeout cases are the ones undici actually produces. + */ + +const AUTH: AuthContext = { mode: 'apiKey', headers: { 'x-app-api-key': 'k' } }; +const BODY = { id: 'upload-1', path: 'uploads/upload-1/upload-1.zip', name: 'run' }; +type SubmitResponse = Awaited>; +// Only the fields these tests look at; the gateway passes the body through. +const CREATED = { + message: 'Test scheduled successfully.', + results: [{ id: 41, test_file_name: 'flow.yaml', test_upload_id: 'upload-1' }], +} as unknown as SubmitResponse; + +/** What the server does with each request, in order. */ +type Reply = 'drop' | 'hold' | { body: unknown; status: number }; + +let server: http.Server; +let baseUrl: string; +let replies: Reply[]; +let bodies: string[]; + +const submit = () => ApiGateway.submitFlowTest(baseUrl, AUTH, BODY); +const noSleep = () => Promise.resolve(); + +describe('flow submit: retry while the outcome is unknown', () => { + before(async () => { + server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + bodies.push(raw); + const reply = replies.shift() ?? { body: { message: 'no reply queued' }, status: 500 }; + if (reply === 'drop') { + req.socket.destroy(); + return; + } + + if (reply === 'hold') return; // never answers, like a held request + + res.writeHead(reply.status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(reply.body)); + }); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + beforeEach(() => { + replies = []; + bodies = []; + }); + + it('sends the same body again after a 502 and returns the run', async () => { + replies.push({ body: { message: 'upstream error' }, status: 502 }, { body: CREATED, status: 201 }); + const notices: string[] = []; + + const { attempts, response } = await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + onRetry: ({ attempt, reason }) => notices.push(`${attempt}:${reason}`), + sleep: noSleep, + submit, + }); + + expect(response).to.deep.equal(CREATED); + expect(attempts).to.equal(2); + expect(bodies).to.have.length(2); + // The upload id is what makes the repeat safe, so it must not change. + expect(bodies[1]).to.equal(bodies[0]); + expect(notices).to.deep.equal(['2:HTTP 502']); + }); + + it('waits out a 409 while the first attempt is still creating the run', async () => { + replies.push( + { body: { code: 'SUBMISSION_IN_PROGRESS', message: 'still being created' }, status: 409 }, + { body: CREATED, status: 201 }, + ); + + const { attempts } = await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + sleep: noSleep, + submit, + }); + + expect(attempts).to.equal(2); + }); + + it('sends again after a dropped connection', async () => { + replies.push('drop', { body: CREATED, status: 201 }); + + const { attempts, response } = await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + sleep: noSleep, + submit, + }); + + expect(response).to.deep.equal(CREATED); + expect(attempts).to.equal(2); + }); + + it('abandons an attempt with no response and sends again', async () => { + const original = ApiGateway.submitAttemptTimeoutMs; + ApiGateway.submitAttemptTimeoutMs = 200; + replies.push('hold', { body: CREATED, status: 201 }); + try { + const reasons: string[] = []; + const { attempts } = await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + onRetry: ({ reason }) => reasons.push(reason), + sleep: noSleep, + submit, + }); + + expect(attempts).to.equal(2); + expect(reasons).to.deep.equal(['no response in time']); + } finally { + ApiGateway.submitAttemptTimeoutMs = original; + server.closeAllConnections(); + } + }); + + it('does not send again when the API refused the request (400)', async () => { + replies.push({ body: { message: 'bad flows' }, status: 400 }); + + const error = await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + sleep: noSleep, + submit, + }).catch((error_: unknown) => error_); + + expect(error).to.be.instanceOf(ApiError); + expect((error as ApiError).status).to.equal(400); + expect(bodies).to.have.length(1); + }); + + it('falls back to multipart only when the first attempt finds no endpoint', async () => { + replies.push({ body: { message: 'Cannot POST' }, status: 404 }); + + const { attempts, response } = await submitWithRetry({ + fallback: (error) => Promise.resolve({ ...CREATED, message: `multipart after ${error.status}` }), + sleep: noSleep, + submit, + }); + + expect(attempts).to.equal(1); + expect(response.message).to.equal('multipart after 404'); + }); + + it('never falls back after a retry: that API already took an attempt', async () => { + replies.push({ body: { message: 'upstream error' }, status: 502 }, { body: { message: 'Cannot POST' }, status: 404 }); + let fellBack = false; + + const error = await submitWithRetry({ + fallback: () => { + fellBack = true; + return Promise.resolve(CREATED); + }, + sleep: noSleep, + submit, + }).catch((error_: unknown) => error_); + + expect(fellBack).to.equal(false); + expect((error as ApiError).status).to.equal(404); + }); + + it('gives up with the last error once the attempts run out', async () => { + replies.push( + { body: { message: 'upstream error' }, status: 503 }, + { body: { message: 'upstream error' }, status: 503 }, + { body: { message: 'upstream error' }, status: 503 }, + ); + + const error = await submitWithRetry({ + delaysMs: [0, 0], + fallback: () => Promise.reject(new Error('must not fall back')), + sleep: noSleep, + submit, + }).catch((error_: unknown) => error_); + + expect((error as ApiError).status).to.equal(503); + expect(bodies).to.have.length(3); + }); + + it('waits the given delays between attempts', async () => { + replies.push( + { body: {}, status: 502 }, + { body: {}, status: 502 }, + { body: CREATED, status: 201 }, + ); + const slept: number[] = []; + + await submitWithRetry({ + delaysMs: [2000, 5000], + fallback: () => Promise.reject(new Error('must not fall back')), + sleep: (ms) => { + slept.push(ms); + return Promise.resolve(); + }, + submit, + }); + + expect(slept).to.deep.equal([2000, 5000]); + }); + + it('reports each attempt before it is sent, so a failure carries the count too', async () => { + replies.push({ body: {}, status: 502 }, { body: { message: 'bad' }, status: 400 }); + const seen: number[] = []; + + await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + onAttempt: (attempt) => seen.push(attempt), + sleep: noSleep, + submit, + }).catch(() => undefined); + + expect(seen).to.deep.equal([1, 2]); + }); + + it('starts no attempt after the time budget is spent', async () => { + // Every attempt "takes" 60s of fake time; the budget allows two waits. + let clock = 0; + let calls = 0; + const error = await submitWithRetry({ + budgetMs: 150_000, + delaysMs: [1000, 1000, 1000, 1000], + fallback: () => Promise.reject(new Error('must not fall back')), + now: () => clock, + sleep: (ms) => { + clock += ms; + return Promise.resolve(); + }, + submit() { + calls += 1; + clock += 60_000; + return Promise.reject(new ApiError('upstream error', 502)); + }, + }).catch((error_: unknown) => error_); + + expect((error as ApiError).status).to.equal(502); + // Attempts start at 0s, 61s and 122s; a fourth would start at 183s. + expect(calls).to.equal(3); + }); + + it('keeps the worst case to minutes, not a quarter of an hour', () => { + // The last attempt starts inside the budget and is cut by its own timeout. + const worstCaseMs = SUBMIT_RETRY_BUDGET_MS + ApiGateway.submitAttemptTimeoutMs; + expect(worstCaseMs).to.be.at.most(7 * 60_000); + }); + + it('does not send again when the session cannot be refreshed', async () => { + for (const refusal of [ + new SessionRefreshError('refresh token revoked', true), + new CliError('No stored session. Run `dcd login`.'), + ]) { + let calls = 0; + const error = await submitWithRetry({ + fallback: () => Promise.reject(new Error('must not fall back')), + sleep: noSleep, + submit() { + calls += 1; + return Promise.reject(refusal); + }, + }).catch((error_: unknown) => error_); + + expect(error).to.equal(refusal); + expect(calls).to.equal(1); + } + }); + + describe('isRetryableSubmitError', () => { + for (const status of [409, 500, 502, 503, 504]) { + it(`retries HTTP ${status}`, () => { + expect(isRetryableSubmitError(new ApiError('x', status))).to.equal(true); + }); + } + + for (const status of [400, 401, 402, 403, 404, 429]) { + it(`does not retry HTTP ${status}`, () => { + expect(isRetryableSubmitError(new ApiError('x', status))).to.equal(false); + }); + } + + it('retries anything that is not an API answer (network, timeout, a body cut off)', () => { + expect(isRetryableSubmitError(new TypeError('terminated'))).to.equal(true); + expect(isRetryableSubmitError(new Error('ECONNRESET'))).to.equal(true); + }); + }); +}); diff --git a/test/unit/priority.test.ts b/test/unit/priority.test.ts new file mode 100644 index 0000000..643ffb7 --- /dev/null +++ b/test/unit/priority.test.ts @@ -0,0 +1,112 @@ +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 { executionFlags } from '../../src/config/flags/execution.flags.js'; +import type { IExecutionPlan } from '../../src/services/execution-plan.service.js'; +import { + type TestSubmissionConfig, + TestSubmissionService, +} from '../../src/services/test-submission.service.js'; +import { validateEnum } from '../../src/utils/cli.js'; + +const PRIORITY_OPTIONS = ['low', 'normal', 'high'] as const; + +/** + * --priority reorders only the org's own queued runs (dcd#1581). It goes to + * the API as a top-level submission field, never inside `config`, and the + * default ('normal') is not sent at all, so older APIs see an unchanged wire + * shape. + */ +describe('priority', () => { + let workspace: string; + let flowFile: string; + + before(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-priority-')); + 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 string-encoded fields the submission would send. */ + async function fieldsFor( + overrides: Partial, + ): Promise> { + const executionPlan: IExecutionPlan = { + flowMetadata: {}, + flowOverrides: {}, + flowsToRun: [flowFile], + includedFiles: [], + referencedFiles: [], + totalFlowFiles: 1, + }; + const { fields } = await new TestSubmissionService().buildTestPayload({ + appBinaryId: 'binary-1', + cliVersion: '0.0.0-test', + commonRoot: workspace, + encrypt: false, + executionPlan, + flowFile, + maestroVersion: '2.10.0', + ...overrides, + }); + return fields; + } + + describe('the --priority flag', () => { + const parse = (argv: string[]) => parseArgs(argv, executionFlags).priority; + + it('defaults to normal', () => { + expect(parse([])).to.equal('normal'); + }); + + it('accepts an explicit value', () => { + expect(parse(['--priority', 'high'])).to.equal('high'); + expect(parse(['--priority', 'low'])).to.equal('low'); + }); + }); + + describe('validation', () => { + it('accepts low, normal and high', () => { + for (const value of PRIORITY_OPTIONS) { + expect(validateEnum(value, PRIORITY_OPTIONS, 'priority')).to.equal( + value, + ); + } + }); + + it('rejects anything else', () => { + expect(() => + validateEnum('urgent', PRIORITY_OPTIONS, 'priority'), + ).to.throw('Invalid value for --priority'); + }); + }); + + describe('the submission payload', () => { + it('sends high as a top-level field', async () => { + expect((await fieldsFor({ priority: 'high' })).priority).to.equal('high'); + }); + + it('sends low as a top-level field', async () => { + expect((await fieldsFor({ priority: 'low' })).priority).to.equal('low'); + }); + + it('sends nothing for normal or when unset', async () => { + expect((await fieldsFor({ priority: 'normal' })).priority).to.equal( + undefined, + ); + expect((await fieldsFor({})).priority).to.equal(undefined); + }); + + it('never puts it inside config', async () => { + const fields = await fieldsFor({ priority: 'high' }); + expect(JSON.parse(fields.config)).to.not.have.property('priority'); + }); + }); +}); diff --git a/test/unit/results-poll-cache.test.ts b/test/unit/results-poll-cache.test.ts new file mode 100644 index 0000000..e24a981 --- /dev/null +++ b/test/unit/results-poll-cache.test.ts @@ -0,0 +1,322 @@ +import { expect } from 'chai'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { ApiGateway, type ResultsPollCache } from '../../src/gateways/api-gateway.js'; +import { ResultsPollingService } from '../../src/services/results-polling.service.js'; +import type { AuthContext } from '../../src/types/domain/auth.types.js'; + +/** + * `dcd cloud` polls `GET /results/:uploadId` every 20-60s for the whole run. + * It asks for the slim `?view=summary` rows and revalidates with an ETag, so + * a poll that finds nothing changed costs an empty 304. + * + * These run against a real HTTP server with the real global fetch, because + * the header that makes the 304 possible at all (`cache-control: max-age=0`) + * only matters to undici: without it, undici appends `Cache-Control: + * no-cache` to the conditional request and Express never answers 304. + */ + +const AUTH: AuthContext = { mode: 'apiKey', headers: { 'x-app-api-key': 'k' } }; + +type Reply = { body?: unknown; etag?: string; status?: number }; +type Seen = { headers: http.IncomingHttpHeaders; url: string }; + +let server: http.Server; +let baseUrl: string; +let replies: Reply[]; +let seen: Seen[]; + +const row = (overrides: Record = {}) => ({ + id: 1, + test_file_name: 'flows/login.yaml', + status: 'RUNNING', + retry_of: null, + duration_seconds: null, + fail_reason: null, + cancellation_reason: null, + simulator_name: 'pixel-7', + config: { deviceName: 'Pixel 7', osVersion: '34' }, + ...overrides, +}); + +const get = (cache?: ResultsPollCache) => + ApiGateway.getResultsForUpload(baseUrl, AUTH, 'u1', cache); + +describe('results poll: summary view and ETag revalidation', () => { + before(async () => { + server = http.createServer((req, res) => { + seen.push({ headers: req.headers, url: req.url ?? '' }); + const reply = replies.shift() ?? { status: 500, body: { message: 'no reply queued' } }; + const status = reply.status ?? 200; + if (reply.etag) res.setHeader('etag', reply.etag); + if (status === 304) { + res.writeHead(304).end(); + return; + } + + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(reply.body)); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + beforeEach(() => { + replies = []; + seen = []; + }); + + describe('ApiGateway.getResultsForUpload', () => { + it('asks for the summary view, unconditionally, on the first poll', async () => { + const body = { results: [row()] }; + replies.push({ body, etag: 'W/"a"' }); + const cache: ResultsPollCache = {}; + + expect(await get(cache)).to.deep.equal(body); + + expect(seen[0].url).to.equal('/results/u1?view=summary'); + expect(seen[0].headers['x-app-api-key']).to.equal('k'); + expect(seen[0].headers).to.not.have.property('if-none-match'); + expect(cache).to.deep.equal({ body, etag: 'W/"a"' }); + }); + + it('revalidates with If-None-Match and max-age=0, not no-cache', async () => { + const body = { results: [row()] }; + replies.push({ body, etag: 'W/"a"' }, { status: 304, etag: 'W/"a"' }); + const cache: ResultsPollCache = {}; + + await get(cache); + const second = await get(cache); + + expect(seen[1].url).to.equal('/results/u1?view=summary'); + expect(seen[1].headers['if-none-match']).to.equal('W/"a"'); + // undici would append `no-cache` here, which Express's `fresh` reads as + // "never 304". The explicit header keeps it out. + expect(seen[1].headers['cache-control']).to.equal('max-age=0'); + expect(second).to.deep.equal(body); + expect(cache.etag).to.equal('W/"a"'); + }); + + it('replaces the cache when the results changed', async () => { + const before = { results: [row()] }; + const after = { results: [row({ status: 'PASSED', duration_seconds: 12 })] }; + replies.push({ body: before, etag: 'W/"a"' }, { body: after, etag: 'W/"b"' }); + const cache: ResultsPollCache = {}; + + await get(cache); + expect(await get(cache)).to.deep.equal(after); + + expect(seen[1].headers['if-none-match']).to.equal('W/"a"'); + expect(cache).to.deep.equal({ body: after, etag: 'W/"b"' }); + }); + + const uncacheable: Array<[string, Reply]> = [ + ['a 200 with no ETag', { body: { results: [row()] } }], + ['an empty result set', { body: { results: [] }, etag: 'W/"e"' }], + [ + 'the error envelope the API sends as a 200', + { body: { statusCode: 400, message: 'Upload not found' }, etag: 'W/"x"' }, + ], + ]; + + for (const [name, reply] of uncacheable) { + it(`does not cache ${name}, so the next poll is unconditional`, async () => { + // Start from a cached set, so this also shows the cache is cleared + // rather than left holding the previous body. + replies.push({ body: { results: [row()] }, etag: 'W/"a"' }, reply, { + body: { results: [row()] }, + etag: 'W/"a"', + }); + const cache: ResultsPollCache = {}; + + await get(cache); + expect(await get(cache)).to.deep.equal(reply.body); + expect(cache).to.deep.equal({}); + await get(cache); + + expect(seen[1].headers['if-none-match']).to.equal('W/"a"'); + expect(seen[2].headers).to.not.have.property('if-none-match'); + expect(seen[2].headers['cache-control']).to.not.equal('max-age=0'); + }); + } + + it('throws on a 304 it did not ask for, and clears the cache', async () => { + replies.push({ status: 304, etag: 'W/"a"' }); + // An ETag with no body to go with it is not enough to revalidate. + const cache: ResultsPollCache = { etag: 'W/"stale"' }; + + let caught: unknown; + try { + await get(cache); + } catch (error) { + caught = error; + } + + expect(seen[0].headers).to.not.have.property('if-none-match'); + expect(caught).to.be.instanceOf(Error); + expect((caught as Error).message).to.include('304'); + expect(cache).to.deep.equal({}); + }); + + it('still polls the summary view without a cache', async () => { + const body = { results: [row()] }; + replies.push({ body, etag: 'W/"a"' }, { body, etag: 'W/"a"' }); + + await get(); + expect(await get()).to.deep.equal(body); + + expect(seen.map((s) => s.url)).to.deep.equal([ + '/results/u1?view=summary', + '/results/u1?view=summary', + ]); + expect(seen[1].headers).to.not.have.property('if-none-match'); + }); + }); + + describe('poll loop', () => { + it('treats a 304 as a successful poll and finishes on the next change', async () => { + replies.push( + { body: { results: [row()] }, etag: 'W/"a"' }, + { status: 304, etag: 'W/"a"' }, + { body: { results: [row({ status: 'PASSED', duration_seconds: 9 })] }, etag: 'W/"b"' }, + ); + const service = new ResultsPollingService(); + // Poll back to back instead of every 20s. + (service as unknown as { APIKEY_POLL_INTERVAL_MS: number }).APIKEY_POLL_INTERVAL_MS = 0; + const logs: string[] = []; + + const result = await service.pollUntilComplete({ + auth: AUTH, + apiUrl: baseUrl, + consoleUrl: 'https://console.example.com/results?upload=u1', + debug: true, + json: true, + logger: (m) => logs.push(m), + uploadId: 'u1', + }); + + expect(result.status).to.equal('PASSED'); + expect(result.tests[0]).to.include({ durationSeconds: 9, status: 'PASSED' }); + expect(seen.map((s) => s.headers['if-none-match'])).to.deep.equal([ + undefined, + 'W/"a"', + 'W/"a"', + ]); + expect(logs).to.include('[DEBUG] Poll not modified (304), reusing 1 cached results'); + expect(logs.some((m) => m.includes('unable to fetch results'))).to.equal(false); + }); + }); + + describe('buildPollingResult', () => { + // buildPollingResult is private; reach it rather than re-implementing the + // verdict, as superseded-run.test.ts does. + const build = (results: unknown[]) => + ( + new ResultsPollingService() as unknown as { + buildPollingResult: ( + r: unknown[], + uploadId: string, + consoleUrl: string, + testMetadata?: Record, + ) => unknown; + } + ).buildPollingResult(results, 'u1', 'https://console/u1', { + 'flows/login.yaml': { flowName: 'Login', tags: ['smoke'] }, + }); + + // What the default view sends: every column, the whole config, and the + // result_files row with its main_log. + const full = (overrides: Record = {}) => ({ + test_name: null, + created_at: '2026-10-07T10:00:00.000Z', + platform: 'android', + result_files: { + id: 9, + main_log: 'x'.repeat(10_000), + screenshots: ['a.png', 'b.png'], + }, + ...row({ + config: { deviceName: 'Pixel 7', osVersion: '34', orientation: 'portrait', retries: 1 }, + ...overrides, + }), + }); + + // What `?view=summary` sends: only the columns the CLI reads, with + // config cut down to deviceName/osVersion and null keys left out. + const summary = (overrides: Record = {}) => { + const { + config, + created_at: _at, + platform: _platform, + result_files: _files, + test_name: _name, + ...kept + } = full(overrides); + const { deviceName, osVersion } = config as { deviceName?: unknown; osVersion?: unknown }; + return { + ...kept, + config: { + ...(deviceName == null ? {} : { deviceName }), + ...(osVersion == null ? {} : { osVersion }), + }, + }; + }; + + const runs: Array<[string, Array>]> = [ + [ + 'a failed run with a retry', + [ + { id: 1, status: 'FAILED', fail_reason: 'Element not found', duration_seconds: 30 }, + { id: 2, status: 'PASSED', retry_of: 1, duration_seconds: 25 }, + { + // A row from before per-result device config: the device falls + // back to simulator_name. + id: 3, + test_file_name: 'flows/checkout.yaml', + status: 'FAILED', + fail_reason: 'Timed out', + duration_seconds: 60, + config: { orientation: 'portrait' }, + simulator_name: 'pixel-7_PLAY', + }, + { + // `config->osVersion` keeps a numeric osVersion numeric. + id: 4, + test_file_name: 'flows/search.yaml', + status: 'PASSED', + duration_seconds: 5, + config: { deviceName: 'iPhone 15', osVersion: 17, orientation: 'portrait' }, + simulator_name: 'iphone-15-17', + }, + ], + ], + [ + 'a superseded run', + [ + { id: 1, status: 'PASSED', duration_seconds: 10 }, + { id: 2, status: 'CANCELLED', cancellation_reason: 'superseded_by:newer-upload' }, + ], + ], + ]; + + for (const [name, overrides] of runs) { + it(`gives the same result for full and summary rows: ${name}`, () => { + const fromFull = build(overrides.map((o) => full(o))); + const fromSummary = build(overrides.map((o) => summary(o))); + + expect((fromFull as { tests: unknown[] }).tests).to.not.be.empty; + expect(fromSummary).to.deep.equal(fromFull); + }); + } + }); +}); diff --git a/test/unit/telemetry-submit-attempts.test.ts b/test/unit/telemetry-submit-attempts.test.ts new file mode 100644 index 0000000..414aca1 --- /dev/null +++ b/test/unit/telemetry-submit-attempts.test.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; + +import { telemetry } from '../../src/services/telemetry.service.js'; + +/** + * How many attempts the flow submit took (dcd#1617 follow-up) rides on the + * lifecycle event as `extra.submit_attempts`. Anything above 1 is a response + * that was lost and a submit that was sent again, which is the number to count + * when judging how often the API stalls under the CLI. + */ +interface SentEvent { + extra?: Record; + message: string; +} + +describe('submit attempts telemetry', () => { + const realFetch = globalThis.fetch; + let events: SentEvent[]; + + beforeEach(function () { + // The singleton reads this once, at import. + if (process.env.DCD_TELEMETRY_DISABLED) this.skip(); + + events = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + events.push(...(JSON.parse(String(init?.body)) as { events: SentEvent[] }).events); + return new Response('{}', { status: 202 }); + }) as typeof fetch; + telemetry.configure({ + apiUrl: 'http://localhost:9999', + auth: { headers: { 'x-app-api-key': 'test-key' }, mode: 'apiKey' }, + }); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + const last = (message: string) => + events.filter((e) => e.message === message).pop(); + + it('puts the attempt count on `command completed`', async () => { + telemetry.recordSubmitAttempts(3); + telemetry.recordCommandSuccess(); + await telemetry.flush(); + + expect(last('command completed')?.extra?.submit_attempts).to.equal(3); + }); + + it('and on `command failed`', async () => { + telemetry.recordSubmitAttempts(9); + telemetry.recordCommandFailure({ error: new Error('boom'), exitCode: 1 }); + await telemetry.flush(); + + expect(last('command failed')?.extra?.submit_attempts).to.equal(9); + }); +});