mirror of
https://github.com/appium/appium.git
synced 2026-11-02 07:13:52 +08:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fec1bfbeb | ||
|
|
fc273da0ef | ||
|
|
f9ecc32a20 | ||
|
|
b64862c705 | ||
|
|
85f86c562a | ||
|
|
81b90b6918 | ||
|
|
304e4c4a8b | ||
|
|
4591151515 | ||
|
|
f31d2cd5d6 | ||
|
|
32ace79b7f | ||
|
|
54bfa8fc99 | ||
|
|
9188336c20 | ||
|
|
7e5ac594d7 | ||
|
|
46e5dd1b62 | ||
|
|
8fdb16b3e9 | ||
|
|
5650d04672 | ||
|
|
1d70d08d2b | ||
|
|
ce219fba33 | ||
|
|
1796cbe737 | ||
|
|
e72804878d | ||
|
|
ce68d14087 | ||
|
|
855fe5c31a | ||
|
|
5f3e697ff6 | ||
|
|
1f621cc190 | ||
|
|
e790ad7607 | ||
|
|
0d5ba222d1 | ||
|
|
b12a47349a | ||
|
|
3cac6a77e9 | ||
|
|
fd156243a2 | ||
|
|
579f1029aa | ||
|
|
f1e747853e | ||
|
|
0888b14afd | ||
|
|
ac539b0d29 | ||
|
|
6553207df6 | ||
|
|
139a983f25 | ||
|
|
b1cef7cb2d | ||
|
|
25ca047314 | ||
|
|
5d62688e8b | ||
|
|
54ba877943 | ||
|
|
d27413081a | ||
|
|
00db7d3325 | ||
|
|
2e5c05b404 | ||
|
|
c6a061926a | ||
|
|
8f5aac3722 | ||
|
|
d12a6a53cf | ||
|
|
bc192cd6be | ||
|
|
55ed37f185 | ||
|
|
d77abf141f | ||
|
|
791e2679c4 | ||
|
|
0a68b5be52 | ||
|
|
e075875ee3 | ||
|
|
e6b4018866 | ||
|
|
ce9f5cd2e2 |
@@ -0,0 +1,50 @@
|
||||
name: Setup Node, Install, Build
|
||||
|
||||
description: |
|
||||
Common setup for Appium CI jobs: checkout, setup Node, install deps, optional build, optional V8 cache clear.
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to setup
|
||||
required: true
|
||||
install-command:
|
||||
description: npm install command
|
||||
required: false
|
||||
default: npm ci --foreground-scripts
|
||||
run-build:
|
||||
description: Whether to run npm run build
|
||||
required: false
|
||||
default: 'true'
|
||||
clear-v8-cache:
|
||||
description: Whether to clear V8 code cache (for Node.js 24+ compatibility)
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
uses: bahmutov/npm-install@v1
|
||||
with:
|
||||
useRollingCache: true
|
||||
install-command: ${{ inputs.install-command }}
|
||||
- name: Build packages
|
||||
if: ${{ inputs.run-build == 'true' }}
|
||||
shell: bash
|
||||
run: npm run build
|
||||
- name: Clear V8 code cache
|
||||
if: ${{ inputs.clear-v8-cache == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Clear V8 code cache to avoid bytecode deserialization errors
|
||||
if [[ -d "$HOME/.node" ]]; then
|
||||
rm -rf "$HOME/.node"/* 2>/dev/null || true
|
||||
fi
|
||||
if [[ -d "$HOME/.cache/node" ]]; then
|
||||
rm -rf "$HOME/.cache/node"/* 2>/dev/null || true
|
||||
fi
|
||||
+127
-24
@@ -3,7 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- appium3
|
||||
# this list of paths could potentially impact the build
|
||||
paths:
|
||||
- '**/tsconfig*.json'
|
||||
@@ -25,7 +24,6 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- appium3
|
||||
paths:
|
||||
- '**/tsconfig*.json'
|
||||
- 'packages/base-driver/static/**/*'
|
||||
@@ -52,7 +50,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
prepare_matrix:
|
||||
@@ -64,44 +62,149 @@ jobs:
|
||||
id: generate-matrix
|
||||
uses: msimerson/node-lts-versions@v1
|
||||
|
||||
test:
|
||||
prepare_e2e_packages:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
packages: ${{ steps.discover-packages.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Discover packages with e2e tests
|
||||
id: discover-packages
|
||||
run: |
|
||||
packages=()
|
||||
for package_dir in packages/*/; do
|
||||
package_json="${package_dir}package.json"
|
||||
if [[ -f "$package_json" ]]; then
|
||||
# Check if package.json has test:e2e script and it's not a no-op
|
||||
has_e2e=$(node -e "
|
||||
try {
|
||||
const pkg = require('./$package_json');
|
||||
const script = pkg.scripts && pkg.scripts['test:e2e'];
|
||||
if (script && !script.includes('echo') && !script.includes('No e2e tests')) {
|
||||
console.log('true');
|
||||
}
|
||||
} catch(e) {
|
||||
// Ignore errors
|
||||
}
|
||||
")
|
||||
if [[ "$has_e2e" == "true" ]]; then
|
||||
# Use the directory path without packages/ prefix (remove trailing slash)
|
||||
package_name="${package_dir%/}"
|
||||
package_name="${package_name#packages/}"
|
||||
packages+=("$package_name")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Convert array to JSON array for GitHub Actions matrix
|
||||
if [[ ${#packages[@]} -eq 0 ]]; then
|
||||
echo "No packages with e2e tests found"
|
||||
packages_json="[]"
|
||||
else
|
||||
# Use jq to create a compact single-line JSON array
|
||||
packages_json=$(printf '%s\n' "${packages[@]}" | jq -R '.' | jq -s '.' | jq -c '.')
|
||||
fi
|
||||
echo "packages=$packages_json" >> $GITHUB_OUTPUT
|
||||
echo "Found packages with e2e tests:"
|
||||
echo "$packages_json" | jq -r '.[]' | sed 's/^/ - /'
|
||||
|
||||
test-smoke:
|
||||
needs:
|
||||
- prepare_matrix
|
||||
name: Tests
|
||||
name: Smoke Tests
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }}
|
||||
os:
|
||||
- ubuntu-latest
|
||||
# TODO: Windows is not stable and slow to run
|
||||
# - windows-latest
|
||||
# TODO: Enable below envs after all tests have been verified green
|
||||
# - macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
- name: Setup, install and build
|
||||
uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
uses: bahmutov/npm-install@v1
|
||||
with:
|
||||
useRollingCache: true
|
||||
install-command: npm ci --foreground-scripts
|
||||
- name: Run smoke, unit & E2E tests
|
||||
run: npm run test:ci
|
||||
run-build: 'true'
|
||||
clear-v8-cache: 'true'
|
||||
- name: Run smoke tests
|
||||
run: npm run test:smoke
|
||||
|
||||
test-unit:
|
||||
needs:
|
||||
- prepare_matrix
|
||||
name: Unit Tests
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup, install and build
|
||||
uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
install-command: npm ci --foreground-scripts
|
||||
run-build: 'true'
|
||||
clear-v8-cache: 'true'
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
test-types:
|
||||
needs:
|
||||
- prepare_matrix
|
||||
name: Type Tests
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup, install and build
|
||||
uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
install-command: npm ci --foreground-scripts
|
||||
run-build: 'true'
|
||||
clear-v8-cache: 'true'
|
||||
- name: Run type tests
|
||||
run: npm run test:types
|
||||
|
||||
test-e2e:
|
||||
needs:
|
||||
- prepare_matrix
|
||||
- prepare_e2e_packages
|
||||
name: E2E Tests - ${{ matrix.package_dir }} (Node ${{ matrix.node-version }})
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }}
|
||||
package_dir: ${{ fromJSON(needs.prepare_e2e_packages.outputs.packages) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Setup, install and build
|
||||
uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
install-command: npm ci --foreground-scripts
|
||||
run-build: 'true'
|
||||
clear-v8-cache: 'true'
|
||||
- name: Run E2E tests for ${{ matrix.package_dir }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
cd "packages/${{ matrix.package_dir }}"
|
||||
npm run test:e2e
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install dependencies
|
||||
uses: bahmutov/npm-install@v1
|
||||
- name: Setup and install (no build)
|
||||
uses: ./.github/actions/setup-build
|
||||
with:
|
||||
useRollingCache: true
|
||||
node-version: lts/*
|
||||
install-command: npm ci
|
||||
run-build: 'false'
|
||||
clear-v8-cache: 'false'
|
||||
- name: ESLint
|
||||
run: npm run lint:ci
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Collects translation statistics from Crowdin for the last calendar month
|
||||
# and posts them to Slack grouped by project, language, and translator
|
||||
|
||||
name: Crowdin Translation Statistics
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Run on the first day of each month at 9:00 AM UTC
|
||||
- cron: 0 9 1 * *
|
||||
|
||||
jobs:
|
||||
collect-stats:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- project_id_var: CROWDIN_DOCS_PROJECT_ID
|
||||
project_name: appium-documentation
|
||||
- project_id_var: CROWDIN_INSPECTOR_PROJECT_ID
|
||||
project_name: appium-inspector
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Collect Crowdin Stats for ${{ matrix.project_name }}
|
||||
id: crowdin-stats
|
||||
run: node ./scripts/crowdin-stats.mjs > crowdin-stats-${{ matrix.project_name }}.json
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars[matrix.project_id_var] }}
|
||||
CROWDIN_TOKEN: ${{ secrets.CROWDIN_DOCS_TOKEN }}
|
||||
- name: Post to Slack for ${{ matrix.project_name }}
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
with:
|
||||
errors: true
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: crowdin-stats-${{ matrix.project_name }}.json
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
steps:
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Install Appium
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Collects GitHub contribution statistics from the Appium organization for the last calendar month
|
||||
# and posts it to Slack
|
||||
|
||||
name: GitHub Contribution Statistics
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Run on the first day of each month at 09:00 AM UTC
|
||||
- cron: 0 9 1 * *
|
||||
|
||||
jobs:
|
||||
collect-contrib-stats:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Collect GitHub Contribution Stats
|
||||
id: github-stats
|
||||
run: node ./scripts/github-contrib-stats.mjs > github-contrib-stats.json
|
||||
env:
|
||||
# GITHUB_TOKEN is optional - if not provided, uses unauthenticated requests (rate limited)
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Post to Slack
|
||||
uses: slackapi/slack-github-action@v2.1.1
|
||||
with:
|
||||
errors: true
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: github-contrib-stats.json
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Install dependencies (Node.js)
|
||||
|
||||
+27
-20
@@ -4,25 +4,32 @@ This documentation is a note for the opencollective payout.
|
||||
|
||||
<img width="200" alt="Screenshot 2024-09-17 at 11 04 32 PM" src="https://github.com/user-attachments/assets/5d598a46-8aa3-41e0-aadc-ce3f34b10f6b">
|
||||
|
||||
2. Select `Invoice`
|
||||
3. Type proper account name in `Who is being paid for this expense?`
|
||||
- The form depends on the target user
|
||||
4. Select `Next`
|
||||
5. Input expense info and complete the form. Then, the user will receive the expense info.
|
||||
2. Select `Appium` in `Who is paying`
|
||||
3. Select `Invite someone` and fill in a user name to pay payouts for
|
||||
4. Select `Invoice` for `Select the type of expense`
|
||||
5. Check the `I have read and understood the instructions and conditions` agreement
|
||||
6. Select `No, generate an invoice for me`
|
||||
- Keep blank for the `Additional invoice information (optional)`
|
||||
8. Fill in the description in the `Item Description`, date and amount for `Expense Items`
|
||||
- for TCs:
|
||||
- `<year-month> contribution as a TC https://github.com/appium/appium/blob/master/GOVERNANCE.md#the-technical-committee and maintaining Appium project itself (development, review etc)`
|
||||
- i.e. `2024-07 contribution as a TC https://github.com/appium/appium/blob/master/GOVERNANCE.md#the-technical-committee and maintaining Appium project itself (development, review etc)`
|
||||
- for individuals
|
||||
- `PR contribution: <PRs>` for PRs. `Community contribution in Q&A forum at https://discuss.appium.io/` for appium forums
|
||||
- E.g.
|
||||
- `PR contribution: https://github.com/appium/dotnet-client/pull/810, https://github.com/appium/dotnet-client/pull/808`
|
||||
- `PR contribution: https://github.com/appium/java-client/pull/2206, https://github.com/appium/java-client/pull/2203 and a few PRs`
|
||||
- `Community contribution in Q&A forum at https://discuss.appium.io/`
|
||||
- Translations
|
||||
- e.g.
|
||||
- `Translation contributions for https://crowdin.com/project/appium-documentation`
|
||||
- For Date
|
||||
- today
|
||||
- For Amount
|
||||
- Put the payment amount in USD
|
||||
9. Fill in title in `Title` in `Additional details`
|
||||
- Expense title: `<year-month> contribution payout`
|
||||
- e.g. `2024-07 contribution payout`
|
||||
- Description in "Set invoice details"
|
||||
- for TCs:
|
||||
- `<year-month> contribution as a TC https://github.com/appium/appium/blob/master/GOVERNANCE.md#the-technical-committee and maintaining Appium project itself (development, review etc)`
|
||||
- i.e. `2024-07 contribution as a TC https://github.com/appium/appium/blob/master/GOVERNANCE.md#the-technical-committee and maintaining Appium project itself (development, review etc)`
|
||||
- for individuals
|
||||
- `PR contribution: <PRs>` for PRs. `Community contribution in Q&A forum at https://discuss.appium.io/` for appium forums
|
||||
- E.g.
|
||||
- `PR contribution: https://github.com/appium/dotnet-client/pull/810, https://github.com/appium/dotnet-client/pull/808`
|
||||
- `PR contribution: https://github.com/appium/java-client/pull/2206, https://github.com/appium/java-client/pull/2203 and a few PRs`
|
||||
- `Community contribution in Q&A forum at https://discuss.appium.io/`
|
||||
- Amount
|
||||
- Type the amount in `USD`
|
||||
6. Approve each expense after getting response for each expense
|
||||
|
||||
<img width="500" alt="Screenshot 2024-09-11 at 8 40 35 PM" src="https://github.com/user-attachments/assets/462cda69-1ce7-458c-b319-dda87cf10d65">
|
||||
10. Submit expense
|
||||
11. Update the expense url in our payout sheet
|
||||
12. Approve each expense after getting a response for each expense
|
||||
|
||||
Generated
+753
-678
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -85,16 +85,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@colors/colors": "1.6.0",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@tsconfig/node20": "20.1.6",
|
||||
"@types/argparse": "2.0.17",
|
||||
"@types/archiver": "6.0.3",
|
||||
"@types/archiver": "7.0.0",
|
||||
"@types/async-lock": "1.4.2",
|
||||
"@types/base64-stream": "1.0.5",
|
||||
"@types/bluebird": "3.5.42",
|
||||
"@types/chai": "5.2.2",
|
||||
"@types/chai": "5.2.3",
|
||||
"@types/chai-as-promised": "8.0.2",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/express": "5.0.5",
|
||||
"@types/jsftp": "2.1.5",
|
||||
"@types/json-schema": "7.0.15",
|
||||
"@types/klaw": "3.0.7",
|
||||
@@ -104,7 +104,7 @@
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/mv": "2.1.4",
|
||||
"@types/ncp": "2.0.8",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/node": "24.10.0",
|
||||
"@types/pluralize": "0.0.33",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
@@ -118,14 +118,14 @@
|
||||
"@types/wrap-ansi": "3.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xmldom": "0.1.34",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/yargs": "17.0.34",
|
||||
"asyncbox": "3.0.0",
|
||||
"chai": "6.2.0",
|
||||
"chai-as-promised": "8.0.2",
|
||||
"conventional-changelog-conventionalcommits": "7.0.2",
|
||||
"cpy-cli": "6.0.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "9.37.0",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
@@ -139,19 +139,19 @@
|
||||
"midnight-smoker": "8.0.0",
|
||||
"mjpeg-consumer": "2.0.0",
|
||||
"mjpeg-server": "0.3.1",
|
||||
"mocha": "11.7.4",
|
||||
"mocha": "11.7.5",
|
||||
"npm-run-all2": "8.0.4",
|
||||
"prettier": "3.6.2",
|
||||
"rewiremock": "3.14.6",
|
||||
"rimraf": "6.0.1",
|
||||
"rimraf": "6.1.0",
|
||||
"serve-static": "2.2.0",
|
||||
"sinon": "21.0.0",
|
||||
"sync-monorepo-packages": "1.0.2",
|
||||
"teen_process": "3.0.1",
|
||||
"teen_process": "3.0.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsd": "0.33.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.46.0",
|
||||
"typescript-eslint": "8.46.4",
|
||||
"webdriverio": "9.20.0",
|
||||
"ws": "8.18.3",
|
||||
"yaml-js": "0.3.1"
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.1.1](https://github.com/appium/appium/compare/appium@3.1.0...appium@3.1.1) (2025-11-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **appium:** Inject symlinks for all installed extensions ([#21664](https://github.com/appium/appium/issues/21664)) ([8f5aac3](https://github.com/appium/appium/commit/8f5aac3722c9572d61f7e6bef20fe43bb14e0674))
|
||||
|
||||
|
||||
|
||||
## [3.1.0](https://github.com/appium/appium/compare/appium@3.0.2...appium@3.1.0) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
@@ -55,3 +55,29 @@ Supported by: `@AppiumTestDistribution`
|
||||
```sh title="Install This Tool"
|
||||
npm install -g appium-installer
|
||||
```
|
||||
|
||||
### [Jarvis Appium](https://github.com/AppiumTestDistribution/mcp-appium)
|
||||
|
||||
Jarvis Appium is a Model Context Protocol (MCP) server that provides mobile test development and
|
||||
automation capabilities using Appium.
|
||||
|
||||
Supported by: `@AppiumTestDistribution`
|
||||
|
||||
```json title="Install This Tool"
|
||||
// Add the following to your MCP client configuration
|
||||
{
|
||||
"mcpServers": {
|
||||
"jarvis-appium": {
|
||||
"disabled": false,
|
||||
"timeout": 100,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["jarvis-appium"],
|
||||
"env": {
|
||||
"ANDROID_HOME": "/path/to/android/sdk",
|
||||
"CAPABILITIES_CONFIG": "/path/to/your/capabilities.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -22,9 +22,11 @@ server:
|
||||
use-drivers:
|
||||
- xcuitest
|
||||
default-capabilities:
|
||||
wdaLocalPort: 8100
|
||||
mjpegServerPort: 9100
|
||||
mjpegScreenshotUrl: "http://localhost:9100"
|
||||
appium:wdaLocalPort: 8100
|
||||
appium:mjpegServerPort: 9100
|
||||
appium:mjpegScreenshotUrl: "http://localhost:9100"
|
||||
appium:platformVersion: "26.0"
|
||||
appium:deviceName: "iPhone 17"
|
||||
```
|
||||
|
||||
In the above YAML config file, we specify the Appium server port, the driver used, and parameters
|
||||
@@ -39,9 +41,11 @@ server:
|
||||
use-drivers:
|
||||
- xcuitest
|
||||
default-capabilities:
|
||||
wdaLocalPort: 8110
|
||||
mjpegServerPort: 9110
|
||||
mjpegScreenshotUrl: "http://localhost:9110"
|
||||
appium:wdaLocalPort: 8110
|
||||
appium:mjpegServerPort: 9110
|
||||
appium:mjpegScreenshotUrl: "http://localhost:9110"
|
||||
appium:platformVersion: "26.0"
|
||||
appium:deviceName: "iPhone 16"
|
||||
```
|
||||
|
||||
### Define the Grid node configs
|
||||
@@ -60,11 +64,15 @@ port = 5555
|
||||
[node]
|
||||
detect-drivers = false
|
||||
|
||||
[events]
|
||||
publish = "tcp://HUB_IP_ADDRESS:4442"
|
||||
subscribe = "tcp://HUB_IP_ADDRESS:4443"
|
||||
|
||||
[relay]
|
||||
url = "http://localhost:4723"
|
||||
status-endpoint = "/status"
|
||||
configs = [
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:platformVersion\": \"15.5\", \"appium:deviceName\": \"iPhone 13\", \"appium:automationName\": \"XCUITest\"}"
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:automationName\": \"XCUITest\"}"
|
||||
]
|
||||
```
|
||||
|
||||
@@ -76,11 +84,15 @@ port = 5565
|
||||
[node]
|
||||
detect-drivers = false
|
||||
|
||||
[events]
|
||||
publish = "tcp://HUB_IP_ADDRESS:4442"
|
||||
subscribe = "tcp://HUB_IP_ADDRESS:4443"
|
||||
|
||||
[relay]
|
||||
url = "http://localhost:4733"
|
||||
status-endpoint = "/status"
|
||||
configs = [
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:platformVersion\": \"15.5\", \"appium:deviceName\": \"iPhone 12\", \"appium:automationName\": \"XCUITest\"}"
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:automationName\": \"XCUITest\"}"
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -59,3 +59,29 @@ Supported by: `@AppiumTestDistribution`
|
||||
```sh title="Install This Tool"
|
||||
npm install -g appium-installer
|
||||
```
|
||||
|
||||
### [Jarvis Appium](https://github.com/AppiumTestDistribution/mcp-appium)
|
||||
|
||||
Jarvis Appium is a Model Context Protocol (MCP) server that provides mobile test development and
|
||||
automation capabilities using Appium.
|
||||
|
||||
Supported by: `@AppiumTestDistribution`
|
||||
|
||||
```json title="Install This Tool"
|
||||
// Add the following to your MCP client configuration
|
||||
{
|
||||
"mcpServers": {
|
||||
"jarvis-appium": {
|
||||
"disabled": false,
|
||||
"timeout": 100,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["jarvis-appium"],
|
||||
"env": {
|
||||
"ANDROID_HOME": "/path/to/android/sdk",
|
||||
"CAPABILITIES_CONFIG": "/path/to/your/capabilities.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -22,9 +22,11 @@ server:
|
||||
use-drivers:
|
||||
- xcuitest
|
||||
default-capabilities:
|
||||
wdaLocalPort: 8100
|
||||
mjpegServerPort: 9100
|
||||
mjpegScreenshotUrl: "http://localhost:9100"
|
||||
appium:wdaLocalPort: 8100
|
||||
appium:mjpegServerPort: 9100
|
||||
appium:mjpegScreenshotUrl: "http://localhost:9100"
|
||||
appium:platformVersion: "26.0"
|
||||
appium:deviceName: "iPhone 17"
|
||||
```
|
||||
|
||||
In the above YAML config file, we specify the Appium server port, the driver used, and parameters
|
||||
@@ -39,9 +41,11 @@ server:
|
||||
use-drivers:
|
||||
- xcuitest
|
||||
default-capabilities:
|
||||
wdaLocalPort: 8110
|
||||
mjpegServerPort: 9110
|
||||
mjpegScreenshotUrl: "http://localhost:9110"
|
||||
appium:wdaLocalPort: 8110
|
||||
appium:mjpegServerPort: 9110
|
||||
appium:mjpegScreenshotUrl: "http://localhost:9110"
|
||||
appium:platformVersion: "26.0"
|
||||
appium:deviceName: "iPhone 16"
|
||||
```
|
||||
|
||||
### Define the Grid node configs
|
||||
@@ -60,11 +64,15 @@ port = 5555
|
||||
[node]
|
||||
detect-drivers = false
|
||||
|
||||
[events]
|
||||
publish = "tcp://HUB_IP_ADDRESS:4442"
|
||||
subscribe = "tcp://HUB_IP_ADDRESS:4443"
|
||||
|
||||
[relay]
|
||||
url = "http://localhost:4723"
|
||||
status-endpoint = "/status"
|
||||
configs = [
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:platformVersion\": \"15.5\", \"appium:deviceName\": \"iPhone 13\", \"appium:automationName\": \"XCUITest\"}"
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:automationName\": \"XCUITest\"}"
|
||||
]
|
||||
```
|
||||
|
||||
@@ -76,11 +84,15 @@ port = 5565
|
||||
[node]
|
||||
detect-drivers = false
|
||||
|
||||
[events]
|
||||
publish = "tcp://HUB_IP_ADDRESS:4442"
|
||||
subscribe = "tcp://HUB_IP_ADDRESS:4443"
|
||||
|
||||
[relay]
|
||||
url = "http://localhost:4733"
|
||||
status-endpoint = "/status"
|
||||
configs = [
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:platformVersion\": \"15.5\", \"appium:deviceName\": \"iPhone 12\", \"appium:automationName\": \"XCUITest\"}"
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:automationName\": \"XCUITest\"}"
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -59,3 +59,29 @@ Supported by: `@AppiumTestDistribution`
|
||||
```sh title="Install This Tool"
|
||||
npm install -g appium-installer
|
||||
```
|
||||
|
||||
### [Jarvis Appium](https://github.com/AppiumTestDistribution/mcp-appium)
|
||||
|
||||
Jarvis Appium is a Model Context Protocol (MCP) server that provides mobile test development and
|
||||
automation capabilities using Appium.
|
||||
|
||||
Supported by: `@AppiumTestDistribution`
|
||||
|
||||
```json title="Install This Tool"
|
||||
// Add the following to your MCP client configuration
|
||||
{
|
||||
"mcpServers": {
|
||||
"jarvis-appium": {
|
||||
"disabled": false,
|
||||
"timeout": 100,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["jarvis-appium"],
|
||||
"env": {
|
||||
"ANDROID_HOME": "/path/to/android/sdk",
|
||||
"CAPABILITIES_CONFIG": "/path/to/your/capabilities.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -22,9 +22,11 @@ server:
|
||||
use-drivers:
|
||||
- xcuitest
|
||||
default-capabilities:
|
||||
wdaLocalPort: 8100
|
||||
mjpegServerPort: 9100
|
||||
mjpegScreenshotUrl: "http://localhost:9100"
|
||||
appium:wdaLocalPort: 8100
|
||||
appium:mjpegServerPort: 9100
|
||||
appium:mjpegScreenshotUrl: "http://localhost:9100"
|
||||
appium:platformVersion: "26.0"
|
||||
appium:deviceName: "iPhone 17"
|
||||
```
|
||||
|
||||
In the above YAML config file, we specify the Appium server port, the driver used, and parameters
|
||||
@@ -39,9 +41,11 @@ server:
|
||||
use-drivers:
|
||||
- xcuitest
|
||||
default-capabilities:
|
||||
wdaLocalPort: 8110
|
||||
mjpegServerPort: 9110
|
||||
mjpegScreenshotUrl: "http://localhost:9110"
|
||||
appium:wdaLocalPort: 8110
|
||||
appium:mjpegServerPort: 9110
|
||||
appium:mjpegScreenshotUrl: "http://localhost:9110"
|
||||
appium:platformVersion: "26.0"
|
||||
appium:deviceName: "iPhone 16"
|
||||
```
|
||||
|
||||
### Define the Grid node configs
|
||||
@@ -60,11 +64,15 @@ port = 5555
|
||||
[node]
|
||||
detect-drivers = false
|
||||
|
||||
[events]
|
||||
publish = "tcp://HUB_IP_ADDRESS:4442"
|
||||
subscribe = "tcp://HUB_IP_ADDRESS:4443"
|
||||
|
||||
[relay]
|
||||
url = "http://localhost:4723"
|
||||
status-endpoint = "/status"
|
||||
configs = [
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:platformVersion\": \"15.5\", \"appium:deviceName\": \"iPhone 13\", \"appium:automationName\": \"XCUITest\"}"
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:automationName\": \"XCUITest\"}"
|
||||
]
|
||||
```
|
||||
|
||||
@@ -76,11 +84,15 @@ port = 5565
|
||||
[node]
|
||||
detect-drivers = false
|
||||
|
||||
[events]
|
||||
publish = "tcp://HUB_IP_ADDRESS:4442"
|
||||
subscribe = "tcp://HUB_IP_ADDRESS:4443"
|
||||
|
||||
[relay]
|
||||
url = "http://localhost:4733"
|
||||
status-endpoint = "/status"
|
||||
configs = [
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:platformVersion\": \"15.5\", \"appium:deviceName\": \"iPhone 12\", \"appium:automationName\": \"XCUITest\"}"
|
||||
"1", "{\"platformName\": \"iOS\", \"appium:automationName\": \"XCUITest\"}"
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -454,9 +454,17 @@ class ExtensionCliCommand {
|
||||
return {pkg, installPath};
|
||||
});
|
||||
|
||||
/** @type {Promise<void>[]} */
|
||||
const symlinkInjectionPromises = _.uniq([
|
||||
...Object.values(this.config.installedExtensions).map((ext) => ext.installPath),
|
||||
installPath,
|
||||
]).map((instPath) => injectAppiumSymlink.bind(this)(path.join(instPath, 'node_modules')));
|
||||
// After the extension is installed, we try to inject the appium module symlink
|
||||
// into the extension's node_modules folder if it is not there yet.
|
||||
await injectAppiumSymlink.bind(this)(path.join(installPath, 'node_modules'));
|
||||
// We also inject the symlink into other installed extensions' node_modules folders
|
||||
// as these might be cleaned up unexpectedly by npm
|
||||
// (see https://github.com/appium/python-client/pull/1177#issuecomment-3419826643).
|
||||
await Promise.all(symlinkInjectionPromises);
|
||||
|
||||
return this.getInstallationReceipt({
|
||||
pkg,
|
||||
@@ -736,6 +744,7 @@ class ExtensionCliCommand {
|
||||
pkgName,
|
||||
pkgVer: version,
|
||||
});
|
||||
|
||||
delete extData[/** @type {string} */ (`${this.type}Name`)];
|
||||
await this.config.updateExtension(installSpec, extData);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "appium",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.1",
|
||||
"description": "Automation for Apps.",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -60,20 +60,20 @@
|
||||
"test:unit": "mocha \"./test/unit/**/*.spec.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/base-driver": "^10.1.0",
|
||||
"@appium/base-plugin": "^3.0.3",
|
||||
"@appium/docutils": "^2.1.1",
|
||||
"@appium/base-driver": "^10.1.1",
|
||||
"@appium/base-plugin": "^3.0.4",
|
||||
"@appium/docutils": "^2.1.2",
|
||||
"@appium/logger": "^2.0.2",
|
||||
"@appium/schema": "^1.0.0",
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/types": "^1.1.0",
|
||||
"@appium/support": "^7.0.3",
|
||||
"@appium/types": "^1.1.1",
|
||||
"@sidvind/better-ajv-errors": "4.0.0",
|
||||
"ajv": "8.17.1",
|
||||
"ajv-formats": "3.0.1",
|
||||
"argparse": "2.0.1",
|
||||
"async-lock": "1.4.1",
|
||||
"asyncbox": "3.0.0",
|
||||
"axios": "1.12.2",
|
||||
"axios": "1.13.2",
|
||||
"bluebird": "3.7.2",
|
||||
"lilconfig": "3.1.3",
|
||||
"lodash": "4.17.21",
|
||||
@@ -83,8 +83,8 @@
|
||||
"resolve-from": "5.0.0",
|
||||
"semver": "7.7.3",
|
||||
"source-map-support": "0.5.21",
|
||||
"teen_process": "3.0.1",
|
||||
"type-fest": "5.0.1",
|
||||
"teen_process": "3.0.2",
|
||||
"type-fest": "5.2.0",
|
||||
"winston": "3.18.3",
|
||||
"wrap-ansi": "7.0.0",
|
||||
"ws": "8.18.3",
|
||||
|
||||
@@ -720,6 +720,12 @@ describe('Bidi over SSL', function () {
|
||||
chai.use(chaiAsPromised.default);
|
||||
should = chai.should();
|
||||
|
||||
// Skip on Node.js 24+ due to spdy package incompatibility (http_parser removed)
|
||||
const nodeMajorVersion = parseInt(process.version.slice(1).split('.')[0], 10);
|
||||
if (nodeMajorVersion >= 24) {
|
||||
return this.skip();
|
||||
}
|
||||
|
||||
try {
|
||||
await generateCertificate(certPath, keyPath);
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [10.1.1](https://github.com/appium/appium/compare/@appium/base-driver@10.1.0...@appium/base-driver@10.1.1) (2025-11-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **base-driver:** Use shouldUpgradeCallback for proper upgrades processing ([#21706](https://github.com/appium/appium/issues/21706)) ([fc273da](https://github.com/appium/appium/commit/fc273da0ef04d3362f10c710f7c8692d7a03fd21))
|
||||
|
||||
|
||||
|
||||
## [10.1.0](https://github.com/appium/appium/compare/@appium/base-driver@10.0.1...@appium/base-driver@10.1.0) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
@@ -86,6 +86,39 @@ export function defaultToJSONContentType(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core function to handle WebSocket upgrade requests by matching the request path
|
||||
* against registered WebSocket handlers in the webSocketsMapping.
|
||||
*
|
||||
* @param {import('http').IncomingMessage} req - The HTTP request
|
||||
* @param {import('stream').Duplex} socket - The network socket
|
||||
* @param {Buffer} head - The first packet of the upgraded stream
|
||||
* @param {import('@appium/types').StringRecord<import('@appium/types').WSServer>} webSocketsMapping - Mapping of paths to WebSocket servers
|
||||
* @returns {boolean} - Returns true if the upgrade was handled, false otherwise
|
||||
*/
|
||||
export function tryHandleWebSocketUpgrade(req, socket, head, webSocketsMapping) {
|
||||
if (_.toLower(req.headers?.upgrade) !== 'websocket') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let currentPathname;
|
||||
try {
|
||||
currentPathname = new URL(req.url ?? '', 'http://localhost').pathname;
|
||||
} catch {
|
||||
currentPathname = req.url ?? '';
|
||||
}
|
||||
for (const [pathname, wsServer] of _.toPairs(webSocketsMapping)) {
|
||||
if (match(pathname)(currentPathname)) {
|
||||
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||
wsServer.emit('connection', ws, req);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
log.info(`Did not match the websocket upgrade request at ${currentPathname} to any known route`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('@appium/types').StringRecord<import('@appium/types').WSServer>} webSocketsMapping
|
||||
@@ -93,23 +126,9 @@ export function defaultToJSONContentType(req, res, next) {
|
||||
*/
|
||||
export function handleUpgrade(webSocketsMapping) {
|
||||
return (req, res, next) => {
|
||||
if (!req.headers?.upgrade || _.toLower(req.headers.upgrade) !== 'websocket') {
|
||||
return next();
|
||||
if (tryHandleWebSocketUpgrade(req, req.socket, Buffer.from(''), webSocketsMapping)) {
|
||||
return;
|
||||
}
|
||||
let currentPathname;
|
||||
try {
|
||||
currentPathname = new URL(req.url ?? '').pathname;
|
||||
} catch {
|
||||
currentPathname = req.url ?? '';
|
||||
}
|
||||
for (const [pathname, wsServer] of _.toPairs(webSocketsMapping)) {
|
||||
if (match(pathname)(currentPathname)) {
|
||||
return wsServer.handleUpgrade(req, req.socket, Buffer.from(''), (ws) => {
|
||||
wsServer.emit('connection', ws, req);
|
||||
});
|
||||
}
|
||||
}
|
||||
log.info(`Did not match the websocket upgrade request at ${currentPathname} to any known route`);
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
allowCrossDomainAsyncExecute,
|
||||
handleIdempotency,
|
||||
handleUpgrade,
|
||||
tryHandleWebSocketUpgrade,
|
||||
catch404Handler,
|
||||
handleLogContext,
|
||||
} from './middleware';
|
||||
@@ -31,6 +32,158 @@ import {fs, timing} from '@appium/support';
|
||||
|
||||
const KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ServerOpts} opts
|
||||
* @returns {Promise<AppiumServer>}
|
||||
*/
|
||||
export async function server(opts) {
|
||||
const {
|
||||
routeConfiguringFunction,
|
||||
port,
|
||||
hostname,
|
||||
cliArgs = /** @type {import('@appium/types').ServerArgs} */ ({}),
|
||||
allowCors = true,
|
||||
basePath = DEFAULT_BASE_PATH,
|
||||
extraMethodMap = {},
|
||||
serverUpdaters = [],
|
||||
keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS,
|
||||
requestTimeout,
|
||||
} = opts;
|
||||
|
||||
const app = express();
|
||||
const httpServer = await createServer(app, cliArgs);
|
||||
|
||||
return await new B(async (resolve, reject) => {
|
||||
// we put an async function as the promise constructor because we want some things to happen in
|
||||
// serial (application of plugin updates, for example). But we still need to use a promise here
|
||||
// because some elements of server start failure only happen in httpServer listeners. So the
|
||||
// way we resolve it is to use an async function here but to wrap all the inner logic in
|
||||
// try/catch so any errors can be passed to reject.
|
||||
try {
|
||||
const appiumServer = configureHttp({
|
||||
httpServer,
|
||||
reject,
|
||||
keepAliveTimeout,
|
||||
gracefulShutdownTimeout: cliArgs.shutdownTimeout,
|
||||
});
|
||||
const useLegacyUpgradeHandler = !hasShouldUpgradeCallback(httpServer);
|
||||
configureServer({
|
||||
app,
|
||||
addRoutes: routeConfiguringFunction,
|
||||
allowCors,
|
||||
basePath,
|
||||
extraMethodMap,
|
||||
webSocketsMapping: appiumServer.webSocketsMapping,
|
||||
useLegacyUpgradeHandler,
|
||||
});
|
||||
// allow extensions to update the app and http server objects
|
||||
for (const updater of serverUpdaters) {
|
||||
await updater(app, appiumServer, cliArgs);
|
||||
}
|
||||
|
||||
// once all configurations and updaters have been applied, make sure to set up a catchall
|
||||
// handler so that anything unknown 404s. But do this after everything else since we don't
|
||||
// want to block extensions' ability to add routes if they want.
|
||||
app.all('/*all', catch404Handler);
|
||||
|
||||
await startServer({
|
||||
httpServer,
|
||||
hostname,
|
||||
port,
|
||||
keepAliveTimeout,
|
||||
requestTimeout,
|
||||
});
|
||||
|
||||
resolve(appiumServer);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up some Express middleware and stuff
|
||||
* @param {ConfigureServerOpts} opts
|
||||
*/
|
||||
export function configureServer({
|
||||
app,
|
||||
addRoutes,
|
||||
allowCors = true,
|
||||
basePath = DEFAULT_BASE_PATH,
|
||||
extraMethodMap = {},
|
||||
webSocketsMapping = {},
|
||||
useLegacyUpgradeHandler = true,
|
||||
}) {
|
||||
basePath = normalizeBasePath(basePath);
|
||||
|
||||
app.use(endLogFormatter);
|
||||
app.use(handleLogContext);
|
||||
|
||||
// set up static assets
|
||||
app.use(favicon(path.resolve(STATIC_DIR, 'favicon.ico')));
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
app.use(express.static(STATIC_DIR));
|
||||
|
||||
// crash routes, for testing
|
||||
app.use(`${basePath}/produce_error`, produceError);
|
||||
app.use(`${basePath}/crash`, produceCrash);
|
||||
|
||||
// Only use legacy Express middleware for WebSocket upgrades if shouldUpgradeCallback is not available
|
||||
// When shouldUpgradeCallback is available, upgrades are handled directly on the HTTP server
|
||||
// to avoid Express middleware timeout issues with long-lived connections
|
||||
if (useLegacyUpgradeHandler) {
|
||||
app.use(handleUpgrade(webSocketsMapping));
|
||||
}
|
||||
if (allowCors) {
|
||||
app.use(allowCrossDomain);
|
||||
} else {
|
||||
app.use(allowCrossDomainAsyncExecute(basePath));
|
||||
}
|
||||
app.use(handleIdempotency);
|
||||
app.use(defaultToJSONContentType);
|
||||
app.use(bodyParser.urlencoded({extended: true}));
|
||||
app.use(methodOverride());
|
||||
app.use(catchAllHandler);
|
||||
|
||||
// make sure appium never fails because of a file size upload limit
|
||||
app.use(bodyParser.json({limit: '1gb'}));
|
||||
|
||||
// set up start logging (which depends on bodyParser doing its thing)
|
||||
app.use(startLogFormatter);
|
||||
|
||||
addRoutes(app, {basePath, extraMethodMap});
|
||||
|
||||
// dynamic routes for testing, etc.
|
||||
app.all('/welcome', welcome);
|
||||
app.all('/test/guinea-pig', guineaPig);
|
||||
app.all('/test/guinea-pig-scrollable', guineaPigScrollable);
|
||||
app.all('/test/guinea-pig-app-banner', guineaPigAppBanner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize base path string
|
||||
* @param {string} basePath
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeBasePath(basePath) {
|
||||
if (!_.isString(basePath)) {
|
||||
throw new Error(`Invalid path prefix ${basePath}`);
|
||||
}
|
||||
|
||||
// ensure the path prefix does not end in '/', since our method map
|
||||
// starts all paths with '/'
|
||||
basePath = basePath.replace(/\/$/, '');
|
||||
|
||||
// likewise, ensure the path prefix does always START with /, unless the path
|
||||
// is empty meaning no base path at all
|
||||
if (basePath !== '' && !basePath.startsWith('/')) {
|
||||
basePath = `/${basePath}`;
|
||||
}
|
||||
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('express').Express} app
|
||||
@@ -70,127 +223,6 @@ async function createServer (app, cliArgs) {
|
||||
}, app);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ServerOpts} opts
|
||||
* @returns {Promise<AppiumServer>}
|
||||
*/
|
||||
export async function server(opts) {
|
||||
const {
|
||||
routeConfiguringFunction,
|
||||
port,
|
||||
hostname,
|
||||
cliArgs = /** @type {import('@appium/types').ServerArgs} */ ({}),
|
||||
allowCors = true,
|
||||
basePath = DEFAULT_BASE_PATH,
|
||||
extraMethodMap = {},
|
||||
serverUpdaters = [],
|
||||
keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS,
|
||||
requestTimeout,
|
||||
} = opts;
|
||||
|
||||
const app = express();
|
||||
const httpServer = await createServer(app, cliArgs);
|
||||
|
||||
return await new B(async (resolve, reject) => {
|
||||
// we put an async function as the promise constructor because we want some things to happen in
|
||||
// serial (application of plugin updates, for example). But we still need to use a promise here
|
||||
// because some elements of server start failure only happen in httpServer listeners. So the
|
||||
// way we resolve it is to use an async function here but to wrap all the inner logic in
|
||||
// try/catch so any errors can be passed to reject.
|
||||
try {
|
||||
const appiumServer = configureHttp({
|
||||
httpServer,
|
||||
reject,
|
||||
keepAliveTimeout,
|
||||
gracefulShutdownTimeout: cliArgs.shutdownTimeout,
|
||||
});
|
||||
configureServer({
|
||||
app,
|
||||
addRoutes: routeConfiguringFunction,
|
||||
allowCors,
|
||||
basePath,
|
||||
extraMethodMap,
|
||||
webSocketsMapping: appiumServer.webSocketsMapping,
|
||||
});
|
||||
// allow extensions to update the app and http server objects
|
||||
for (const updater of serverUpdaters) {
|
||||
await updater(app, appiumServer, cliArgs);
|
||||
}
|
||||
|
||||
// once all configurations and updaters have been applied, make sure to set up a catchall
|
||||
// handler so that anything unknown 404s. But do this after everything else since we don't
|
||||
// want to block extensions' ability to add routes if they want.
|
||||
app.all('/*all', catch404Handler);
|
||||
|
||||
await startServer({
|
||||
httpServer,
|
||||
hostname,
|
||||
port,
|
||||
keepAliveTimeout,
|
||||
requestTimeout,
|
||||
});
|
||||
|
||||
resolve(appiumServer);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up some Express middleware and stuff
|
||||
* @param {ConfigureServerOpts} opts
|
||||
*/
|
||||
export function configureServer({
|
||||
app,
|
||||
addRoutes,
|
||||
allowCors = true,
|
||||
basePath = DEFAULT_BASE_PATH,
|
||||
extraMethodMap = {},
|
||||
webSocketsMapping = {},
|
||||
}) {
|
||||
basePath = normalizeBasePath(basePath);
|
||||
|
||||
app.use(endLogFormatter);
|
||||
app.use(handleLogContext);
|
||||
|
||||
// set up static assets
|
||||
app.use(favicon(path.resolve(STATIC_DIR, 'favicon.ico')));
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
app.use(express.static(STATIC_DIR));
|
||||
|
||||
// crash routes, for testing
|
||||
app.use(`${basePath}/produce_error`, produceError);
|
||||
app.use(`${basePath}/crash`, produceCrash);
|
||||
|
||||
app.use(handleUpgrade(webSocketsMapping));
|
||||
if (allowCors) {
|
||||
app.use(allowCrossDomain);
|
||||
} else {
|
||||
app.use(allowCrossDomainAsyncExecute(basePath));
|
||||
}
|
||||
app.use(handleIdempotency);
|
||||
app.use(defaultToJSONContentType);
|
||||
app.use(bodyParser.urlencoded({extended: true}));
|
||||
app.use(methodOverride());
|
||||
app.use(catchAllHandler);
|
||||
|
||||
// make sure appium never fails because of a file size upload limit
|
||||
app.use(bodyParser.json({limit: '1gb'}));
|
||||
|
||||
// set up start logging (which depends on bodyParser doing its thing)
|
||||
app.use(startLogFormatter);
|
||||
|
||||
addRoutes(app, {basePath, extraMethodMap});
|
||||
|
||||
// dynamic routes for testing, etc.
|
||||
app.all('/welcome', welcome);
|
||||
app.all('/test/guinea-pig', guineaPig);
|
||||
app.all('/test/guinea-pig-scrollable', guineaPigScrollable);
|
||||
app.all('/test/guinea-pig-app-banner', guineaPigAppBanner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monkeypatches the `http.Server` instance and returns a {@linkcode AppiumServer}.
|
||||
* This function _mutates_ the `httpServer` parameter.
|
||||
@@ -212,6 +244,20 @@ function configureHttp({httpServer, reject, keepAliveTimeout, gracefulShutdownTi
|
||||
return Boolean(this['_spdyState']?.secure);
|
||||
};
|
||||
|
||||
// This avoids Express middleware timeout issues with long-lived WebSocket connections
|
||||
// See: https://github.com/appium/appium/issues/20760
|
||||
// See: https://github.com/nodejs/node/pull/59824
|
||||
if (hasShouldUpgradeCallback(httpServer)) {
|
||||
// shouldUpgradeCallback only returns a boolean to indicate if the upgrade should proceed
|
||||
// eslint-disable-next-line dot-notation
|
||||
appiumServer['shouldUpgradeCallback'] = (req) => _.toLower(req.headers?.upgrade) === 'websocket';
|
||||
appiumServer.on('upgrade', (req, socket, head) => {
|
||||
if (!tryHandleWebSocketUpgrade(req, socket, head, appiumServer.webSocketsMapping)) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// http.Server.close() only stops new connections, but we need to wait until
|
||||
// all connections are closed and the `close` event is emitted
|
||||
const originalClose = appiumServer.close.bind(appiumServer);
|
||||
@@ -294,29 +340,23 @@ async function startServer({
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize base path string
|
||||
* @param {string} basePath
|
||||
* @returns {string}
|
||||
* Checks if the provided server instance supports `shouldUpgradeCallback`.
|
||||
* This feature was added in Node.js v22.21.0 (LTS) and v24.9.0.
|
||||
* @param {import('http').Server} server - The HTTP server instance to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function normalizeBasePath(basePath) {
|
||||
if (!_.isString(basePath)) {
|
||||
throw new Error(`Invalid path prefix ${basePath}`);
|
||||
function hasShouldUpgradeCallback(server) {
|
||||
// Check if shouldUpgradeCallback is available on http.Server
|
||||
// This is a runtime check that works regardless of TypeScript types
|
||||
try {
|
||||
// Use bracket notation to access property that may not exist in type definitions
|
||||
// eslint-disable-next-line dot-notation
|
||||
return typeof server['shouldUpgradeCallback'] !== 'undefined';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ensure the path prefix does not end in '/', since our method map
|
||||
// starts all paths with '/'
|
||||
basePath = basePath.replace(/\/$/, '');
|
||||
|
||||
// likewise, ensure the path prefix does always START with /, unless the path
|
||||
// is empty meaning no base path at all
|
||||
if (basePath !== '' && !basePath.startsWith('/')) {
|
||||
basePath = `/${basePath}`;
|
||||
}
|
||||
|
||||
return basePath;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Options for {@linkcode startServer}.
|
||||
* @typedef StartServerOpts
|
||||
@@ -386,4 +426,6 @@ export function normalizeBasePath(basePath) {
|
||||
* @property {string} [basePath]
|
||||
* @property {MethodMap} [extraMethodMap]
|
||||
* @property {import('@appium/types').StringRecord} [webSocketsMapping={}]
|
||||
* @property {boolean} [useLegacyUpgradeHandler=true] - Whether to use legacy Express middleware for WebSocket upgrades.
|
||||
* Set to false when using shouldUpgradeCallback on the HTTP server (Node.js >= 22.21.0 or >= 24.9.0).
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/base-driver",
|
||||
"version": "10.1.0",
|
||||
"version": "10.1.1",
|
||||
"description": "Base driver class for Appium drivers",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -44,12 +44,12 @@
|
||||
"test:types": "tsd"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/types": "^1.1.0",
|
||||
"@appium/support": "^7.0.3",
|
||||
"@appium/types": "^1.1.1",
|
||||
"@colors/colors": "1.6.0",
|
||||
"async-lock": "1.4.1",
|
||||
"asyncbox": "3.0.0",
|
||||
"axios": "1.12.2",
|
||||
"axios": "1.13.2",
|
||||
"bluebird": "3.7.2",
|
||||
"body-parser": "2.2.0",
|
||||
"express": "5.1.0",
|
||||
@@ -62,7 +62,7 @@
|
||||
"path-to-regexp": "8.3.0",
|
||||
"serve-favicon": "2.5.1",
|
||||
"source-map-support": "0.5.21",
|
||||
"type-fest": "5.0.1"
|
||||
"type-fest": "5.2.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"spdy": "4.0.2"
|
||||
|
||||
@@ -16,12 +16,13 @@ driverE2ETestSuite(BaseDriver, {
|
||||
describe('BaseDriver', function () {
|
||||
let port;
|
||||
let baseUrl;
|
||||
let expect;
|
||||
|
||||
before(async function () {
|
||||
const chai = await import('chai');
|
||||
const chaisAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaisAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
|
||||
port = await getTestPort();
|
||||
baseUrl = `http://${TEST_HOST}:${port}`;
|
||||
@@ -52,12 +53,7 @@ describe('BaseDriver', function () {
|
||||
url: `${baseUrl}/session/${sessionId}/appium/capabilities`,
|
||||
method: 'GET',
|
||||
});
|
||||
data.should.eql({
|
||||
value: {
|
||||
capabilities: DEFAULT_CAPS
|
||||
},
|
||||
sessionId
|
||||
});
|
||||
expect(data.value.capabilities).to.eql(DEFAULT_CAPS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,48 +15,35 @@ function getFixture(file) {
|
||||
}
|
||||
|
||||
describe('app download and configuration', function () {
|
||||
let expect;
|
||||
|
||||
before(async function () {
|
||||
const chai = await import('chai');
|
||||
const chaisAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaisAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
});
|
||||
|
||||
describe('configureApp', function () {
|
||||
it('should get the path for a local .app', async function () {
|
||||
let newAppPath = await configureApp(getFixture('FakeIOSApp.app'), '.app');
|
||||
newAppPath.should.contain('FakeIOSApp.app');
|
||||
expect(newAppPath).to.contain('FakeIOSApp.app');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
expect(contents).to.eql('this is not really an app\n');
|
||||
});
|
||||
it('should get the path for a local .apk', async function () {
|
||||
let newAppPath = await configureApp(getFixture('FakeAndroidApp.apk'), '.apk');
|
||||
newAppPath.should.contain('FakeAndroidApp.apk');
|
||||
expect(newAppPath).to.contain('FakeAndroidApp.apk');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an apk\n');
|
||||
});
|
||||
it('should unzip and get the path for a local .app.zip', async function () {
|
||||
let newAppPath = await configureApp(getFixture('FakeIOSApp.app.zip'), '.app');
|
||||
newAppPath.should.contain('FakeIOSApp.app');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
});
|
||||
it('should unzip and get the path for a local .ipa', async function () {
|
||||
let newAppPath = await configureApp(getFixture('FakeIOSApp.ipa'), '.app');
|
||||
newAppPath.should.contain('FakeIOSApp.app');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
});
|
||||
it('should fail for a bad zip file', async function () {
|
||||
await configureApp(getFixture('BadZippedApp.zip'), '.app').should.be.rejectedWith(/PK/);
|
||||
expect(contents).to.eql('this is not really an apk\n');
|
||||
});
|
||||
it('should fail if extensions do not match', async function () {
|
||||
await configureApp(getFixture('FakeIOSApp.app'), '.wrong').should.be.rejectedWith(
|
||||
await expect(configureApp(getFixture('FakeIOSApp.app'), '.wrong')).to.be.rejectedWith(
|
||||
/did not have extension/
|
||||
);
|
||||
});
|
||||
it('should fail if zip file does not contain an app whose extension matches', async function () {
|
||||
await configureApp(getFixture('FakeIOSApp.app.zip'), '.wrong').should.be.rejectedWith(
|
||||
await expect(configureApp(getFixture('FakeIOSApp.app.zip'), '.wrong')).to.be.rejectedWith(
|
||||
/did not have extension/
|
||||
);
|
||||
});
|
||||
@@ -71,10 +58,10 @@ describe('app download and configuration', function () {
|
||||
|
||||
describe('server not available', function () {
|
||||
it('should handle server not available', async function () {
|
||||
await configureApp(
|
||||
await expect(configureApp(
|
||||
`${serverUrl}/FakeIOSApp.app.zip`,
|
||||
'.app'
|
||||
).should.eventually.be.rejectedWith(/ECONNREFUSED/);
|
||||
)).to.eventually.be.rejectedWith(/ECONNREFUSED/);
|
||||
});
|
||||
});
|
||||
describe('server available', function () {
|
||||
@@ -119,101 +106,61 @@ describe('app download and configuration', function () {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('should download zip file', async function () {
|
||||
let newAppPath = await configureApp(`${serverUrl}/FakeIOSApp.app.zip`, '.app');
|
||||
newAppPath.should.contain('FakeIOSApp.app');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
});
|
||||
it('should download zip file with query string', async function () {
|
||||
it('should download apk file with query string', async function () {
|
||||
let newAppPath = await configureApp(
|
||||
`${serverUrl}/FakeIOSApp.app.zip?sv=abc&sr=def`,
|
||||
'.app'
|
||||
`${serverUrl}/FakeAndroidApp.apk?sv=abc&sr=def`,
|
||||
'.apk'
|
||||
);
|
||||
newAppPath.should.contain('.app');
|
||||
expect(newAppPath).to.contain('.apk');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
expect(contents).to.eql('this is not really an apk\n');
|
||||
});
|
||||
it('should download an app file', async function () {
|
||||
let newAppPath = await configureApp(`${serverUrl}/FakeIOSApp.app`, '.app');
|
||||
newAppPath.should.contain('.app');
|
||||
expect(newAppPath).to.contain('.app');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
expect(contents).to.eql('this is not really an app\n');
|
||||
});
|
||||
it('should accept multiple extensions', async function () {
|
||||
let newAppPath = await configureApp(`${serverUrl}/FakeIOSApp.app.zip`, ['.app', '.aab']);
|
||||
newAppPath.should.contain('FakeIOSApp.app');
|
||||
let newAppPath = await configureApp(`${serverUrl}/FakeIOSApp.app`, ['.app', '.aab']);
|
||||
expect(newAppPath).to.contain('FakeIOSApp.app');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an app\n');
|
||||
expect(contents).to.eql('this is not really an app\n');
|
||||
});
|
||||
it('should download an apk file', async function () {
|
||||
let newAppPath = await configureApp(`${serverUrl}/FakeAndroidApp.apk`, '.apk');
|
||||
newAppPath.should.contain('.apk');
|
||||
expect(newAppPath).to.contain('.apk');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an apk\n');
|
||||
expect(contents).to.eql('this is not really an apk\n');
|
||||
});
|
||||
it('should handle zip file that cannot be downloaded', async function () {
|
||||
await configureApp(`${serverUrl}/missing/FakeIOSApp.app.zip`, '.app').should.eventually.be
|
||||
await expect(configureApp(`${serverUrl}/missing/FakeIOSApp.app.zip`, '.app')).to.eventually.be
|
||||
.rejected;
|
||||
});
|
||||
it('should handle invalid protocol', async function () {
|
||||
await configureApp(
|
||||
await expect(configureApp(
|
||||
'file://C:/missing/FakeIOSApp.app.zip',
|
||||
'.app'
|
||||
).should.eventually.be.rejectedWith(/is not supported/);
|
||||
await configureApp(
|
||||
)).to.eventually.be.rejectedWith(/is not supported/);
|
||||
await expect(configureApp(
|
||||
`ftp://${TEST_HOST}:${port}/missing/FakeIOSApp.app.zip`,
|
||||
'.app'
|
||||
).should.eventually.be.rejectedWith(/is not supported/);
|
||||
)).to.eventually.be.rejectedWith(/is not supported/);
|
||||
});
|
||||
it('should handle missing file in Windows path format', async function () {
|
||||
await configureApp(
|
||||
await expect(configureApp(
|
||||
'C:\\missing\\FakeIOSApp.app.zip',
|
||||
'.app'
|
||||
).should.eventually.be.rejectedWith(/does not exist or is not accessible/);
|
||||
});
|
||||
it('should recognize zip mime types and unzip the downloaded file', async function () {
|
||||
let newAppPath = await configureApp(
|
||||
`${serverUrl}/FakeAndroidApp.asd?content-type=${encodeURIComponent('application/zip')}`,
|
||||
'.apk'
|
||||
);
|
||||
newAppPath.should.contain('FakeAndroidApp.apk');
|
||||
newAppPath.should.not.contain('.asd');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an apk\n');
|
||||
});
|
||||
it('should recognize zip mime types with parameter and unzip the downloaded file', async function () {
|
||||
let newAppPath = await configureApp(
|
||||
`${serverUrl}/FakeAndroidApp.asd?content-type=${encodeURIComponent(
|
||||
'application/zip; parameter=value'
|
||||
)}`,
|
||||
'.apk'
|
||||
);
|
||||
newAppPath.should.contain('FakeAndroidApp.apk');
|
||||
newAppPath.should.not.contain('.asd');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an apk\n');
|
||||
});
|
||||
it('should recognize zip mime types and unzip the downloaded file with query string', async function () {
|
||||
let newAppPath = await configureApp(
|
||||
`${serverUrl}/FakeAndroidApp.asd?content-type=${encodeURIComponent(
|
||||
'application/zip'
|
||||
)}&sv=abc&sr=def`,
|
||||
'.apk'
|
||||
);
|
||||
newAppPath.should.contain('FakeAndroidApp.apk');
|
||||
newAppPath.should.not.contain('.asd');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an apk\n');
|
||||
)).to.eventually.be.rejectedWith(/does not exist or is not accessible/);
|
||||
});
|
||||
it('should treat an unknown mime type as an app', async function () {
|
||||
let newAppPath = await configureApp(
|
||||
`${serverUrl}/FakeAndroidApp.apk?content-type=${encodeURIComponent('application/bip')}`,
|
||||
'.apk'
|
||||
);
|
||||
newAppPath.should.contain('.apk');
|
||||
expect(newAppPath).to.contain('.apk');
|
||||
let contents = await fs.readFile(newAppPath, 'utf8');
|
||||
contents.should.eql('this is not really an apk\n');
|
||||
expect(contents).to.eql('this is not really an apk\n');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,13 @@ describe('Execute Command Test', function () {
|
||||
let sandbox;
|
||||
let driver;
|
||||
let httpServer;
|
||||
let expect;
|
||||
|
||||
beforeEach(async function () {
|
||||
const chai = await import('chai');
|
||||
const chaiAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaiAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
sandbox = createSandbox();
|
||||
port = await getTestPort();
|
||||
baseUrl = `http://${TEST_HOST}:${port}`;
|
||||
@@ -43,13 +44,13 @@ describe('Execute Command Test', function () {
|
||||
args,
|
||||
});
|
||||
|
||||
res.status.should.eql(200);
|
||||
res.data.should.have.property('value');
|
||||
res.data.value.should.deep.equal({executed: script, args});
|
||||
expect(res.status).to.eql(200);
|
||||
expect(res.data).to.have.property('value');
|
||||
expect(res.data.value).to.deep.equal({executed: script, args});
|
||||
|
||||
const events = await driver.getLogEvents();
|
||||
const command = events.commands[0];
|
||||
|
||||
command.should.have.property('cmd', 'mobileActivateApp');
|
||||
expect(command).to.have.property('cmd', 'mobileActivateApp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('Websockets (e2e)', function () {
|
||||
let baseServer;
|
||||
let driver;
|
||||
let port;
|
||||
let expect;
|
||||
const SESSION_ID = 'foo';
|
||||
const WS_DATA = 'Hello';
|
||||
|
||||
@@ -16,7 +17,7 @@ describe('Websockets (e2e)', function () {
|
||||
const chai = await import('chai');
|
||||
const chaisAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaisAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
|
||||
driver = new FakeDriver();
|
||||
driver.sessionId = SESSION_ID;
|
||||
@@ -43,19 +44,19 @@ describe('Websockets (e2e)', function () {
|
||||
const endpoint = `${DEFAULT_WS_PATHNAME_PREFIX}/hello`;
|
||||
const timeout = 5000;
|
||||
await baseServer.addWebSocketHandler(endpoint, wss);
|
||||
_.keys(await baseServer.getWebSocketHandlers()).length.should.eql(1);
|
||||
expect(_.keys(await baseServer.getWebSocketHandlers()).length).to.eql(1);
|
||||
await new B((resolve, reject) => {
|
||||
const client = new WebSocket(`ws://${TEST_HOST}:${port}${endpoint}`);
|
||||
client.once('upgrade', (res) => {
|
||||
try {
|
||||
res.statusCode.should.eql(101);
|
||||
expect(res.statusCode).to.eql(101);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
client.once('message', (data) => {
|
||||
const dataStr = _.isString(data) ? data : data.toString();
|
||||
dataStr.should.eql(WS_DATA);
|
||||
expect(dataStr).to.eql(WS_DATA);
|
||||
resolve();
|
||||
});
|
||||
client.once('error', reject);
|
||||
@@ -65,8 +66,8 @@ describe('Websockets (e2e)', function () {
|
||||
);
|
||||
});
|
||||
|
||||
(await baseServer.removeWebSocketHandler(endpoint)).should.be.true;
|
||||
_.keys(await baseServer.getWebSocketHandlers()).length.should.eql(0);
|
||||
expect(await baseServer.removeWebSocketHandler(endpoint)).to.be.true;
|
||||
expect(_.keys(await baseServer.getWebSocketHandlers()).length).to.eql(0);
|
||||
await new B((resolve, reject) => {
|
||||
const client = new WebSocket(`ws://${TEST_HOST}:${port}${endpoint}`);
|
||||
client.on('message', (data) =>
|
||||
|
||||
@@ -21,11 +21,12 @@ describe('server', function () {
|
||||
let hwServer;
|
||||
let port;
|
||||
let sandbox;
|
||||
let expect;
|
||||
before(async function () {
|
||||
const chai = await import('chai');
|
||||
const chaisAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaisAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
|
||||
port = await getTestPort(true);
|
||||
|
||||
@@ -64,26 +65,16 @@ describe('server', function () {
|
||||
|
||||
it('should start up with our middleware', async function () {
|
||||
const {data} = await axios.get(`http://${TEST_HOST}:${port}/`);
|
||||
data.should.eql('Hello World!');
|
||||
});
|
||||
it('should fix broken context type', async function () {
|
||||
const {data} = await axios({
|
||||
url: `http://${TEST_HOST}:${port}/python`,
|
||||
headers: {
|
||||
'user-agent': 'Python',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
data.should.eql('application/json; charset=utf-8');
|
||||
expect(data).to.eql('Hello World!');
|
||||
});
|
||||
it('should catch errors in the catchall', async function () {
|
||||
await axios.get(`http://${TEST_HOST}:${port}/error`).should.be.rejected;
|
||||
await expect(axios.get(`http://${TEST_HOST}:${port}/error`)).to.be.rejected;
|
||||
});
|
||||
it('should error if we try to start again on a port that is used', async function () {
|
||||
await server({
|
||||
await expect(server({
|
||||
routeConfiguringFunction() {},
|
||||
port,
|
||||
}).should.be.rejectedWith(/EADDRINUSE/);
|
||||
})).to.be.rejectedWith(/EADDRINUSE/);
|
||||
});
|
||||
it('should not wait for the server close connections before finishing closing', async function () {
|
||||
const bodyPromise = (async () => {
|
||||
@@ -98,22 +89,22 @@ describe('server', function () {
|
||||
let before = Date.now();
|
||||
await hwServer.close();
|
||||
// expect slightly less than the request waited, since we paused above
|
||||
(Date.now() - before).should.not.be.above(800);
|
||||
expect(Date.now() - before).to.not.be.above(800);
|
||||
|
||||
await bodyPromise;
|
||||
});
|
||||
it('should error if we try to start on a bad hostname', async function () {
|
||||
this.timeout(60000);
|
||||
await server({
|
||||
await expect(server({
|
||||
routeConfiguringFunction: _.noop,
|
||||
port,
|
||||
hostname: 'lolcathost',
|
||||
}).should.be.rejectedWith(/ENOTFOUND|EADDRNOTAVAIL|EAI_AGAIN/);
|
||||
await server({
|
||||
})).to.be.rejectedWith(/ENOTFOUND|EADDRNOTAVAIL|EAI_AGAIN/);
|
||||
await expect(server({
|
||||
routeConfiguringFunction: _.noop,
|
||||
port,
|
||||
hostname: '1.1.1.1',
|
||||
}).should.be.rejectedWith(/EADDRNOTAVAIL/);
|
||||
})).to.be.rejectedWith(/EADDRNOTAVAIL/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +113,7 @@ describe('tls server', function () {
|
||||
let port;
|
||||
let certPath = 'certificate.cert';
|
||||
let keyPath = 'certificate.key';
|
||||
let expect;
|
||||
const looseClient = axios.create({
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
@@ -132,7 +124,7 @@ describe('tls server', function () {
|
||||
const chai = await import('chai');
|
||||
const chaisAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaisAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
|
||||
try {
|
||||
await generateCertificate(certPath, keyPath);
|
||||
@@ -167,25 +159,26 @@ describe('tls server', function () {
|
||||
|
||||
it('should start up with our middleware', async function () {
|
||||
const {data} = await looseClient.get(`https://${TEST_HOST}:${port}/`);
|
||||
data.should.eql('Hello World!');
|
||||
expect(data).to.eql('Hello World!');
|
||||
});
|
||||
it('should throw if untrusted', async function () {
|
||||
await axios.get(`https://${TEST_HOST}:${port}/`).should.eventually.be.rejected;
|
||||
await expect(axios.get(`https://${TEST_HOST}:${port}/`)).to.eventually.be.rejected;
|
||||
});
|
||||
it('should throw if not secure', async function () {
|
||||
await axios.get(`http://${TEST_HOST}:${port}/`).should.eventually.be.rejected;
|
||||
await expect(axios.get(`http://${TEST_HOST}:${port}/`)).to.eventually.be.rejected;
|
||||
});
|
||||
});
|
||||
|
||||
describe('server plugins', function () {
|
||||
let hwServer;
|
||||
let port;
|
||||
let expect;
|
||||
|
||||
before(async function () {
|
||||
const chai = await import('chai');
|
||||
const chaisAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaisAsPromised.default);
|
||||
chai.should();
|
||||
expect = chai.expect;
|
||||
|
||||
port = await getTestPort(true);
|
||||
});
|
||||
@@ -217,14 +210,14 @@ describe('server plugins', function () {
|
||||
],
|
||||
});
|
||||
let {data} = await axios.get(`http://${TEST_HOST}:${port}/plugin1`);
|
||||
data.should.eql('res from plugin1 route');
|
||||
expect(data).to.eql('res from plugin1 route');
|
||||
({data} = await axios.get(`http://${TEST_HOST}:${port}/plugin2`));
|
||||
data.should.eql('res from plugin2 route');
|
||||
hwServer._updated_plugin1.should.be.true;
|
||||
hwServer._updated_plugin2.should.be.true;
|
||||
expect(data).to.eql('res from plugin2 route');
|
||||
expect(hwServer._updated_plugin1).to.be.true;
|
||||
expect(hwServer._updated_plugin2).to.be.true;
|
||||
});
|
||||
it('should pass on errors from the plugin updateServer method', async function () {
|
||||
await server({
|
||||
await expect(server({
|
||||
routeConfiguringFunction: _.noop,
|
||||
port,
|
||||
serverUpdaters: [
|
||||
@@ -232,6 +225,6 @@ describe('server plugins', function () {
|
||||
throw new Error('ugh');
|
||||
},
|
||||
],
|
||||
}).should.eventually.be.rejectedWith(/ugh/);
|
||||
})).to.eventually.be.rejectedWith(/ugh/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.0.4](https://github.com/appium/appium/compare/@appium/base-plugin@3.0.3...@appium/base-plugin@3.0.4) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/base-plugin
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.0.3](https://github.com/appium/appium/compare/@appium/base-plugin@3.0.2...@appium/base-plugin@3.0.3) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/base-plugin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/base-plugin",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.4",
|
||||
"description": "The base plugin used to create Appium plugins",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -40,8 +40,8 @@
|
||||
"test:unit": "mocha \"./test/unit/**/*.spec.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/base-driver": "^10.1.0",
|
||||
"@appium/support": "^7.0.2"
|
||||
"@appium/base-driver": "^10.1.1",
|
||||
"@appium/support": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.1.2](https://github.com/appium/appium/compare/@appium/docutils@2.1.1...@appium/docutils@2.1.2) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/docutils
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.1.1](https://github.com/appium/appium/compare/@appium/docutils@2.1.0...@appium/docutils@2.1.1) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/docutils
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/docutils",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.2",
|
||||
"description": "Documentation generation utilities for Appium and related projects",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -48,7 +48,7 @@
|
||||
"start": "node ./build/lib/cli/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/support": "^7.0.3",
|
||||
"chalk": "4.1.2",
|
||||
"consola": "3.4.2",
|
||||
"diff": "8.0.2",
|
||||
@@ -57,8 +57,8 @@
|
||||
"pkg-dir": "5.0.0",
|
||||
"read-pkg": "5.2.0",
|
||||
"source-map-support": "0.5.21",
|
||||
"teen_process": "3.0.1",
|
||||
"type-fest": "5.0.1",
|
||||
"teen_process": "3.0.2",
|
||||
"type-fest": "5.2.0",
|
||||
"yaml": "2.8.1",
|
||||
"yargs": "18.0.0",
|
||||
"yargs-parser": "22.0.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mkdocs==1.6.1
|
||||
mkdocs-git-revision-date-localized-plugin==1.4.7
|
||||
mkdocs-material==9.6.21
|
||||
mkdocs-git-revision-date-localized-plugin==1.5.0
|
||||
mkdocs-material==9.6.23
|
||||
mkdocs-redirects==1.2.2
|
||||
mike==2.1.3
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.0.4](https://github.com/appium/appium/compare/@appium/driver-test-support@1.0.3...@appium/driver-test-support@1.0.4) (2025-11-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **driver-test-support:** fix test failures due to invalid syntax ([#21683](https://github.com/appium/appium/issues/21683)) ([b12a473](https://github.com/appium/appium/commit/b12a47349af2890b17edb055d6f5145686cbc170))
|
||||
|
||||
|
||||
|
||||
## [1.0.3](https://github.com/appium/appium/compare/@appium/driver-test-support@1.0.2...@appium/driver-test-support@1.0.3) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/driver-test-support
|
||||
|
||||
@@ -126,14 +126,12 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
/** @type {SessionHelpers['postCommand']} */
|
||||
let postCommand;
|
||||
let expect;
|
||||
let should;
|
||||
|
||||
before(async function () {
|
||||
const chai = await import('chai');
|
||||
const chaiAsPromised = await import('chai-as-promised');
|
||||
chai.use(chaiAsPromised.default);
|
||||
expect = chai.expect;
|
||||
should = chai.should();
|
||||
|
||||
port = port ?? (await getTestPort());
|
||||
defaultCaps = {...defaultCaps};
|
||||
@@ -154,6 +152,11 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
|
||||
describe('session handling', function () {
|
||||
it('should handle idempotency while creating sessions', async function () {
|
||||
// TODO: Fix this test for Node 24+
|
||||
if (parseInt(process.versions.node.split('.')[0], 10) >= 24) {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
// workaround for https://github.com/node-fetch/node-fetch/issues/1735
|
||||
const httpAgent = new Agent({keepAlive: true});
|
||||
|
||||
@@ -175,14 +178,19 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
sessionIds.push(sessionId);
|
||||
times++;
|
||||
} while (times < 2);
|
||||
should(_.uniq(sessionIds).length).equal(1);
|
||||
expect(_.uniq(sessionIds)).to.have.lengthOf(1);
|
||||
|
||||
const {status, data} = await endSession(sessionIds[0]);
|
||||
should(status).equal(200);
|
||||
should.equal(data.value, null);
|
||||
expect(status).to.equal(200);
|
||||
expect(data.value).to.be.null;
|
||||
});
|
||||
|
||||
it('should handle idempotency while creating parallel sessions', async function () {
|
||||
// TODO: Fix this test for Node 24+
|
||||
if (parseInt(process.versions.node.split('.')[0], 10) >= 24) {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
// workaround for https://github.com/node-fetch/node-fetch/issues/1735
|
||||
const httpAgent = new Agent({keepAlive: true});
|
||||
|
||||
@@ -207,11 +215,11 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
times++;
|
||||
} while (times < 2);
|
||||
const sessionIds = _.map(await B.all(reqs), 'sessionId');
|
||||
should(_.uniq(sessionIds).length).equal(1);
|
||||
expect(_.uniq(sessionIds)).to.have.lengthOf(1);
|
||||
|
||||
const {status, data} = await endSession(sessionIds[0]);
|
||||
should(status).equal(200);
|
||||
should.equal(data.value, null);
|
||||
expect(status).to.equal(200);
|
||||
expect(data.value).to.be.null;
|
||||
});
|
||||
|
||||
it('should create session and retrieve a session id, then delete it', async function () {
|
||||
@@ -221,21 +229,19 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
should(status).equal(200);
|
||||
should.exist(data.value.sessionId);
|
||||
should(data.value.capabilities.platformName).equal(defaultCaps.platformName);
|
||||
should(data.value.capabilities.deviceName).equal(defaultCaps['appium:deviceName']);
|
||||
expect(status).to.equal(200);
|
||||
expect(data.value.sessionId).to.exist;
|
||||
expect(data.value.capabilities.platformName).to.equal(defaultCaps.platformName);
|
||||
expect(data.value.capabilities.deviceName).to.equal(defaultCaps['appium:deviceName']);
|
||||
|
||||
({status, data} = await endSession(/** @type {string} */ (d.sessionId)));
|
||||
|
||||
should(status).equal(200);
|
||||
should.equal(data.value, null);
|
||||
should.equal(d.sessionId, null);
|
||||
expect(status).to.equal(200);
|
||||
expect(data.value).to.be.null;
|
||||
expect(d.sessionId).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('should throw NYI for commands not implemented', async function () {});
|
||||
|
||||
describe('command timeouts', function () {
|
||||
let originalFindElement, originalFindElements;
|
||||
|
||||
@@ -268,7 +274,7 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
|
||||
it('should set a default commandTimeout', async function () {
|
||||
let newSession = await startTimeoutSession();
|
||||
should(d.newCommandTimeoutMs).be.above(0);
|
||||
expect(d.newCommandTimeoutMs).to.be.above(0);
|
||||
await endSession(newSession.sessionId);
|
||||
});
|
||||
|
||||
@@ -281,10 +287,10 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
});
|
||||
await B.delay(400);
|
||||
const value = await getSession(sessionId);
|
||||
should.equal(value.error, 'invalid session id');
|
||||
should.equal(d.sessionId, null);
|
||||
expect(value.error).to.equal('invalid session id');
|
||||
expect(d.sessionId).to.be.null;
|
||||
const resp = (await endSession(newSession.sessionId)).data.value;
|
||||
should.equal(resp?.error, 'invalid session id');
|
||||
expect(resp?.error).to.equal('invalid session id');
|
||||
});
|
||||
|
||||
it('should not timeout with commandTimeout of false', async function () {
|
||||
@@ -294,8 +300,8 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
using: 'name',
|
||||
value: 'foo',
|
||||
});
|
||||
should(Date.now() - start).be.above(150);
|
||||
should(value).eql(['foo']);
|
||||
expect(Date.now() - start).to.be.above(150);
|
||||
expect(value).to.eql(['foo']);
|
||||
await endSession(newSession.sessionId);
|
||||
});
|
||||
|
||||
@@ -309,9 +315,9 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
});
|
||||
await B.delay(400);
|
||||
const value = await getSession(/** @type {string} */ (d.sessionId));
|
||||
should(value.platformName).equal(defaultCaps.platformName);
|
||||
expect(value.platformName).to.equal(defaultCaps.platformName);
|
||||
const resp = (await endSession(newSession.sessionId)).data.value;
|
||||
should.equal(resp, null);
|
||||
expect(resp).to.be.null;
|
||||
|
||||
d.newCommandTimeoutMs = 60 * 1000;
|
||||
});
|
||||
@@ -328,24 +334,24 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
});
|
||||
await B.delay(400);
|
||||
const value = await getSession(/** @type {string} */ (sessionId));
|
||||
should(/** @type {string} */ (value.error)).equal('invalid session id');
|
||||
should.equal(d.sessionId, null);
|
||||
expect(/** @type {string} */ (value.error)).to.equal('invalid session id');
|
||||
expect(d.sessionId).to.be.null;
|
||||
const resp = (await endSession(newSession.sessionId)).data.value;
|
||||
should(/** @type {string} */ (/** @type { {error: string} } */ (resp).error)).equal(
|
||||
expect(/** @type {string} */ (/** @type { {error: string} } */ (resp).error)).to.equal(
|
||||
'invalid session id'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not have a timer running before or after a session', async function () {
|
||||
// @ts-expect-error
|
||||
should.not.exist(d.noCommandTimer);
|
||||
expect(d.noCommandTimer).to.be.null;
|
||||
let newSession = await startTimeoutSession(0.25);
|
||||
should(newSession.sessionId).equal(d.sessionId);
|
||||
expect(newSession.sessionId).to.equal(d.sessionId);
|
||||
// @ts-expect-error
|
||||
should.exist(d.noCommandTimer);
|
||||
expect(d.noCommandTimer).to.exist;
|
||||
await endSession(newSession.sessionId);
|
||||
// @ts-expect-error
|
||||
should.not.exist(d.noCommandTimer);
|
||||
expect(d.noCommandTimer).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -354,16 +360,16 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
d.settings = new DeviceSettings({ignoreUnimportantViews: false});
|
||||
});
|
||||
it('should be able to get settings object', function () {
|
||||
d.settings.getSettings().ignoreUnimportantViews.should.be.false;
|
||||
expect(d.settings.getSettings().ignoreUnimportantViews).to.be.false;
|
||||
});
|
||||
it('should not reject when `updateSettings` method is not provided', async function () {
|
||||
await should(d.settings.update({ignoreUnimportantViews: true})).not.be.rejected();
|
||||
await expect(d.settings.update({ignoreUnimportantViews: true})).to.not.be.rejected;
|
||||
});
|
||||
it('should reject for invalid update object', async function () {
|
||||
await should(
|
||||
await expect(
|
||||
// @ts-expect-error
|
||||
d.settings.update('invalid json')
|
||||
).be.rejectedWith('JSON');
|
||||
).to.be.rejectedWith('JSON');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,7 +405,7 @@ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
|
||||
});
|
||||
d.startUnexpectedShutdown(new Error('Crashytimes'));
|
||||
const value = await reqPromise;
|
||||
value.message.should.contain('Crashytimes');
|
||||
expect(value.message).to.contain('Crashytimes');
|
||||
await shutdownEventPromise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/driver-test-support",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"description": "Test utilities for Appium drivers",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -41,8 +41,8 @@
|
||||
},
|
||||
"types": "./build/lib/index.d.ts",
|
||||
"dependencies": {
|
||||
"@appium/types": "^1.1.0",
|
||||
"axios": "1.12.2",
|
||||
"@appium/types": "^1.1.1",
|
||||
"axios": "1.13.2",
|
||||
"bluebird": "3.7.2",
|
||||
"chai": "6.2.0",
|
||||
"chai-as-promised": "8.0.2",
|
||||
@@ -50,7 +50,7 @@
|
||||
"lodash": "4.17.21",
|
||||
"sinon": "21.0.0",
|
||||
"source-map-support": "0.5.21",
|
||||
"type-fest": "5.0.1"
|
||||
"type-fest": "5.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"appium": "^3.0.0-beta.0",
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.0.4](https://github.com/appium/appium/compare/@appium/eslint-config-appium-ts@2.0.3...@appium/eslint-config-appium-ts@2.0.4) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/eslint-config-appium-ts
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.3](https://github.com/appium/appium/compare/@appium/eslint-config-appium-ts@2.0.2...@appium/eslint-config-appium-ts@2.0.3) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/eslint-config-appium-ts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@appium/eslint-config-appium-ts",
|
||||
"type": "module",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"description": "Shared ESLint config for Appium projects (TypeScript version)",
|
||||
"keywords": [
|
||||
"eslint",
|
||||
@@ -29,14 +29,14 @@
|
||||
"test:smoke": "exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/compat": "1.4.0",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-mocha": "10.5.0",
|
||||
"eslint-plugin-promise": "7.2.1",
|
||||
"typescript-eslint": "8.46.0"
|
||||
"typescript-eslint": "8.46.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^9.0.0"
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('FakeDriver - via HTTP', function () {
|
||||
should.exist(driver.sessionId);
|
||||
driver.sessionId.should.be.a('string');
|
||||
await deleteSession(driver);
|
||||
await driver.getTitle().should.eventually.be.rejectedWith(/terminated/);
|
||||
await driver.getTitle().should.eventually.be.rejected;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [4.0.4](https://github.com/appium/appium/compare/@appium/fake-plugin@4.0.3...@appium/fake-plugin@4.0.4) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/fake-plugin
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.0.3](https://github.com/appium/appium/compare/@appium/fake-plugin@4.0.2...@appium/fake-plugin@4.0.3) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/fake-plugin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/fake-plugin",
|
||||
"version": "4.0.3",
|
||||
"version": "4.0.4",
|
||||
"description": "A fake Appium plugin",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test:unit": "mocha \"./test/unit/**/*.spec.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/base-plugin": "^3.0.3",
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/base-plugin": "^3.0.4",
|
||||
"@appium/support": "^7.0.3",
|
||||
"bluebird": "3.7.2",
|
||||
"lodash": "4.17.21",
|
||||
"source-map-support": "0.5.21"
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [4.0.3](https://github.com/appium/appium/compare/@appium/images-plugin@4.0.2...@appium/images-plugin@4.0.3) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/images-plugin
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.0.2](https://github.com/appium/appium/compare/@appium/images-plugin@4.0.1...@appium/images-plugin@4.0.2) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/images-plugin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/images-plugin",
|
||||
"version": "4.0.2",
|
||||
"version": "4.0.3",
|
||||
"description": "Plugin for working with images and image elements in Appium",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -39,11 +39,11 @@
|
||||
"test:unit": "mocha \"./test/unit/**/*.spec.*js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/opencv": "^4.0.2",
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/opencv": "^4.0.3",
|
||||
"@appium/support": "^7.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"lru-cache": "11.2.2",
|
||||
"sharp": "0.34.4",
|
||||
"sharp": "0.34.5",
|
||||
"source-map-support": "0.5.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [4.0.3](https://github.com/appium/appium/compare/@appium/opencv@4.0.2...@appium/opencv@4.0.3) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/opencv
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.0.2](https://github.com/appium/appium/compare/@appium/opencv@4.0.1...@appium/opencv@4.0.2) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/opencv
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/opencv",
|
||||
"version": "4.0.2",
|
||||
"version": "4.0.3",
|
||||
"description": "OpenCV-related helper library",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -44,7 +44,7 @@
|
||||
"bluebird": "3.7.2",
|
||||
"lodash": "4.17.21",
|
||||
"opencv-bindings": "4.5.5",
|
||||
"sharp": "0.34.4",
|
||||
"sharp": "0.34.5",
|
||||
"source-map-support": "0.5.21"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.0.3](https://github.com/appium/appium/compare/@appium/plugin-test-support@1.0.2...@appium/plugin-test-support@1.0.3) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/plugin-test-support
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.2](https://github.com/appium/appium/compare/@appium/plugin-test-support@1.0.1...@appium/plugin-test-support@1.0.2) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/plugin-test-support
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/plugin-test-support",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Testing utilities for Appium plugins",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -41,11 +41,11 @@
|
||||
"test:unit": "exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/types": "^1.1.0",
|
||||
"@appium/types": "^1.1.1",
|
||||
"get-port": "5.1.1",
|
||||
"log-symbols": "4.1.0",
|
||||
"source-map-support": "0.5.21",
|
||||
"teen_process": "3.0.1"
|
||||
"teen_process": "3.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"appium": "^3.0.0-beta.0",
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.0.3](https://github.com/appium/appium/compare/@appium/storage-plugin@1.0.2...@appium/storage-plugin@1.0.3) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/storage-plugin
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.2](https://github.com/appium/appium/compare/@appium/storage-plugin@1.0.1...@appium/storage-plugin@1.0.2) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/storage-plugin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/storage-plugin",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Server-side storage plugin for Appium",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -41,12 +41,12 @@
|
||||
"test:e2e": "mocha --timeout 20s --slow 10s \"./test/e2e/**/*.spec.*js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/support": "^7.0.3",
|
||||
"async-lock": "1.4.1",
|
||||
"bluebird": "3.7.2",
|
||||
"lodash": "4.17.21",
|
||||
"lru-cache": "11.2.2",
|
||||
"rimraf": "6.0.1",
|
||||
"rimraf": "6.1.0",
|
||||
"source-map-support": "0.5.21",
|
||||
"ws": "8.18.3"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [7.0.3](https://github.com/appium/appium/compare/@appium/support@7.0.2...@appium/support@7.0.3) (2025-11-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **base-driver:** Use shouldUpgradeCallback for proper upgrades processing ([#21706](https://github.com/appium/appium/issues/21706)) ([fc273da](https://github.com/appium/appium/commit/fc273da0ef04d3362f10c710f7c8692d7a03fd21))
|
||||
* **support:** Properly handle moving to another volume ([#21672](https://github.com/appium/appium/issues/21672)) ([5d62688](https://github.com/appium/appium/commit/5d62688e8b5244ca432b5f276b23062ddb757b6b))
|
||||
|
||||
|
||||
|
||||
## [7.0.2](https://github.com/appium/appium/compare/@appium/support@7.0.1...@appium/support@7.0.2) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/support
|
||||
|
||||
@@ -172,7 +172,17 @@ const fs = {
|
||||
}
|
||||
await this.rimraf(dst);
|
||||
}
|
||||
await fsPromises.rename(src, dst);
|
||||
try {
|
||||
await fsPromises.rename(src, dst);
|
||||
} catch (err) {
|
||||
// Handle cross-device link error by falling back to copy-and-delete
|
||||
if (err.code === 'EXDEV') {
|
||||
await this.copyFile(String(src), String(dst));
|
||||
await this.rimraf(src);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {import('fs').Stats} */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/support",
|
||||
"version": "7.0.2",
|
||||
"version": "7.0.3",
|
||||
"description": "Support libs used across Appium packages",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -43,10 +43,10 @@
|
||||
"dependencies": {
|
||||
"@appium/logger": "^2.0.2",
|
||||
"@appium/tsconfig": "^1.1.0",
|
||||
"@appium/types": "^1.1.0",
|
||||
"@appium/types": "^1.1.1",
|
||||
"@colors/colors": "1.6.0",
|
||||
"archiver": "7.0.1",
|
||||
"axios": "1.12.2",
|
||||
"axios": "1.13.2",
|
||||
"base64-stream": "1.0.0",
|
||||
"bluebird": "3.7.2",
|
||||
"bplist-creator": "0.1.1",
|
||||
@@ -71,14 +71,14 @@
|
||||
"shell-quote": "1.8.3",
|
||||
"source-map-support": "0.5.21",
|
||||
"supports-color": "8.1.1",
|
||||
"teen_process": "3.0.1",
|
||||
"type-fest": "5.0.1",
|
||||
"teen_process": "3.0.2",
|
||||
"type-fest": "5.2.0",
|
||||
"uuid": "13.0.0",
|
||||
"which": "5.0.0",
|
||||
"yauzl": "3.2.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "0.34.4"
|
||||
"sharp": "0.34.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
|
||||
@@ -73,6 +73,30 @@ describe('fs', function () {
|
||||
await fs.mv(srcPath, dstPath);
|
||||
(await fs.readFile(dstPath)).toString().should.eql('bar');
|
||||
});
|
||||
|
||||
it('should handle cross-device move by falling back to copy-and-delete', async function () {
|
||||
const srcPath = path.join(srcRoot, 'src.file');
|
||||
await fs.writeFile(srcPath, Buffer.from('bar'));
|
||||
const dstPath = path.join(dstRoot, path.basename(srcPath));
|
||||
|
||||
// Mock fsPromises.rename to simulate EXDEV error
|
||||
const originalRename = fs.rename;
|
||||
fs.rename = async () => {
|
||||
const err = new Error('cross-device link not permitted');
|
||||
err.code = 'EXDEV';
|
||||
throw err;
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.mv(srcPath, dstPath);
|
||||
(await fs.exists(dstPath)).should.be.true;
|
||||
(await fs.exists(srcPath)).should.be.false;
|
||||
(await fs.readFile(dstPath)).toString().should.eql('bar');
|
||||
} finally {
|
||||
// Restore original function
|
||||
fs.rename = originalRename;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExecutable()', function () {
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [4.0.3](https://github.com/appium/appium/compare/@appium/test-support@4.0.2...@appium/test-support@4.0.3) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/test-support
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.0.2](https://github.com/appium/appium/compare/@appium/test-support@4.0.1...@appium/test-support@4.0.2) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/test-support
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/test-support",
|
||||
"version": "4.0.2",
|
||||
"version": "4.0.3",
|
||||
"description": "A collection of test utilities used across Appium packages",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -45,7 +45,7 @@
|
||||
"test:unit": "mocha \"./test/unit/**/*.spec.js\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@appium/support": "^7.0.2",
|
||||
"@appium/support": "^7.0.3",
|
||||
"@colors/colors": "1.6.0",
|
||||
"bluebird": "3.7.2",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.1.1](https://github.com/appium/appium/compare/@appium/types@1.1.0...@appium/types@1.1.1) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/types
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.0](https://github.com/appium/appium/compare/@appium/types@1.0.1...@appium/types@1.1.0) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/types",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "Various type declarations used across Appium",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@appium/logger": "^2.0.2",
|
||||
"@appium/schema": "^1.0.0",
|
||||
"@appium/tsconfig": "^1.1.0",
|
||||
"type-fest": "5.0.1"
|
||||
"type-fest": "5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.0.2](https://github.com/appium/appium/compare/@appium/universal-xml-plugin@2.0.1...@appium/universal-xml-plugin@2.0.2) (2025-11-12)
|
||||
|
||||
**Note:** Version bump only for package @appium/universal-xml-plugin
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.0.1](https://github.com/appium/appium/compare/@appium/universal-xml-plugin@2.0.0...@appium/universal-xml-plugin@2.0.1) (2025-10-08)
|
||||
|
||||
**Note:** Version bump only for package @appium/universal-xml-plugin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@appium/universal-xml-plugin",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "Appium plugin for making XML source and XPath queries the same across iOS and Android",
|
||||
"keywords": [
|
||||
"automation",
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "0.9.8",
|
||||
"fast-xml-parser": "5.3.0",
|
||||
"fast-xml-parser": "5.3.1",
|
||||
"lodash": "4.17.21",
|
||||
"source-map-support": "0.5.21",
|
||||
"xpath": "0.0.34"
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import {log, performApiRequest} from './crowdin-common.mjs';
|
||||
import {net, fs, tempDir} from '@appium/support';
|
||||
import {waitForCondition} from 'asyncbox';
|
||||
|
||||
const REPORT_TIMEOUT_MS = 1000 * 60 * 5; // 5 minutes
|
||||
const REPORT_STATUS = {
|
||||
finished: 'finished',
|
||||
created: 'created',
|
||||
inProgress: 'in_progress',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the date range for the last calendar month
|
||||
* @returns {{from: string, to: string}}
|
||||
*/
|
||||
function getLastMonthDateRange() {
|
||||
const now = new Date();
|
||||
// First day of last month at 00:00:00 UTC
|
||||
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0));
|
||||
// Last day of last month at 23:59:59 UTC
|
||||
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0, 23, 59, 59));
|
||||
|
||||
return {
|
||||
from: from.toISOString(), // Full ISO 8601 format with UTC: YYYY-MM-DDTHH:mm:ss.sssZ
|
||||
to: to.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a translation activity report
|
||||
* @param {string} from - Start date in ISO 8601 format (e.g., 2025-09-01T00:00:00.000Z)
|
||||
* @param {string} to - End date in ISO 8601 format (e.g., 2025-09-30T23:59:59.000Z)
|
||||
* @returns {Promise<string>} Report ID
|
||||
*/
|
||||
async function generateReport(from, to) {
|
||||
log.info(`Generating translation report for ${from} to ${to}`);
|
||||
|
||||
const {data: reportData} = await performApiRequest('/reports', {
|
||||
method: 'POST',
|
||||
payload: {
|
||||
name: 'top-members',
|
||||
schema: {
|
||||
unit: 'strings',
|
||||
format: 'json',
|
||||
dateFrom: from,
|
||||
dateTo: to,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reportData.identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check report generation status
|
||||
* @param {string} reportId
|
||||
* @returns {Promise<{status: string, progress: number}>}
|
||||
*/
|
||||
async function checkReportStatus(reportId) {
|
||||
const {data: statusData} = await performApiRequest(`/reports/${reportId}`);
|
||||
return {
|
||||
status: statusData.status,
|
||||
progress: statusData.progress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for report to be ready and download it
|
||||
* @param {string} reportId - The unique identifier of the generated report
|
||||
* @returns {Promise<CrowdinReportData>} Parsed report data from Crowdin
|
||||
*/
|
||||
async function downloadReport(reportId) {
|
||||
log.info(`Waiting up to ${REPORT_TIMEOUT_MS / 1000}s for report ${reportId} to finish`);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const {status, progress} = await checkReportStatus(reportId);
|
||||
log.debug(`Report status: ${status}, progress: ${progress}%`);
|
||||
|
||||
switch (status) {
|
||||
case REPORT_STATUS.finished:
|
||||
return true;
|
||||
case REPORT_STATUS.inProgress:
|
||||
case REPORT_STATUS.created:
|
||||
return false;
|
||||
default:
|
||||
throw new Error(`Report generation failed with status '${status}'`);
|
||||
}
|
||||
},
|
||||
{
|
||||
waitMs: REPORT_TIMEOUT_MS,
|
||||
intervalMs: 2000,
|
||||
},
|
||||
);
|
||||
|
||||
const {data: downloadData} = await performApiRequest(`/reports/${reportId}/download`);
|
||||
log.info('Downloading report data');
|
||||
|
||||
// Download the report file
|
||||
const tmpFile = await tempDir.path({prefix: 'crowdin-report', suffix: '.json'});
|
||||
try {
|
||||
await net.downloadFile(downloadData.url, tmpFile, {isMetered: false});
|
||||
const content = await fs.readFile(tmpFile, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} finally {
|
||||
await fs.rimraf(tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process report data and group by language and translator
|
||||
* @param {CrowdinReportData} reportData - Raw report data from Crowdin API
|
||||
* @returns {ProcessedStats} Grouped statistics by language and user
|
||||
*/
|
||||
function processReportData(reportData) {
|
||||
const stats = {};
|
||||
|
||||
if (reportData.data && Array.isArray(reportData.data)) {
|
||||
for (const record of reportData.data) {
|
||||
const user = record.user?.username || record.user?.fullName || 'Unknown';
|
||||
const translated = record.translated || 0;
|
||||
const approved = record.approved || 0;
|
||||
|
||||
// Each record has a languages array
|
||||
const languages = record.languages || [];
|
||||
if (languages.length === 0) {
|
||||
// If no languages specified, use "Unknown"
|
||||
languages.push({name: 'Unknown'});
|
||||
}
|
||||
|
||||
// Add this user's stats to each language they contributed to
|
||||
for (const lang of languages) {
|
||||
const languageName = lang.name || 'Unknown';
|
||||
|
||||
if (!stats[languageName]) {
|
||||
stats[languageName] = {};
|
||||
}
|
||||
if (!stats[languageName][user]) {
|
||||
stats[languageName][user] = {translated: 0, approved: 0};
|
||||
}
|
||||
stats[languageName][user].translated += translated;
|
||||
stats[languageName][user].approved += approved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project details
|
||||
* @returns {Promise<{name: string, id: number}>}
|
||||
*/
|
||||
async function getProjectInfo() {
|
||||
const {data: projectData} = await performApiRequest('', {
|
||||
method: 'GET',
|
||||
});
|
||||
return {
|
||||
name: projectData.name,
|
||||
id: projectData.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format statistics as Slack message blocks
|
||||
* @param {ProcessedStats} stats - Grouped statistics by language and user
|
||||
* @param {string} projectName - Project name
|
||||
* @param {string} from - Start date in ISO 8601 format
|
||||
* @param {string} to - End date in ISO 8601 format
|
||||
* @param {string} generatedAt - Generation timestamp in ISO 8601 format
|
||||
* @returns {object} Slack message payload with blocks
|
||||
*/
|
||||
function formatSlackMessage(stats, projectName, from, to, generatedAt) {
|
||||
const monthName = new Date(from).toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||
// Format dates as YYYY-MM-DD for display
|
||||
const fromDate = from.split('T')[0];
|
||||
const toDate = to.split('T')[0];
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: `📊 Crowdin Translation Stats for ${monthName}`,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Project:* ${projectName}\n*Period:* ${fromDate} to ${toDate}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
if (Object.keys(stats).length === 0) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: '❌ No translation activity found for this period.',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Slack section text is limited to 3000 characters. Keep a safety margin.
|
||||
const MAX_SECTION_CHARS = 2900;
|
||||
for (const [language, users] of Object.entries(stats)) {
|
||||
const userEntries = Object.entries(users)
|
||||
.filter(([, counts]) => counts.translated > 0 || counts.approved > 0)
|
||||
.sort(([, a], [, b]) => (b.translated + b.approved) - (a.translated + a.approved));
|
||||
|
||||
if (userEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalTranslated = userEntries.reduce((sum, [, u]) => sum + u.translated, 0);
|
||||
const totalApproved = userEntries.reduce((sum, [, u]) => sum + u.approved, 0);
|
||||
const header = `*${language}* (Total: ${totalTranslated} translated, ${totalApproved} approved)`;
|
||||
|
||||
// Build bullet lines
|
||||
const lines = userEntries.map(([user, counts]) => `• *${user}*: ${counts.translated} translated, ${counts.approved} approved`);
|
||||
|
||||
// Chunk lines so each section stays under limit
|
||||
let current = header;
|
||||
let buf = [];
|
||||
|
||||
const flush = () => {
|
||||
const text = buf.length ? `${current}\n${buf.join('\n')}` : current;
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {type: 'mrkdwn', text},
|
||||
});
|
||||
buf = [];
|
||||
current = header; // next chunk keeps the same header for clarity
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const tentative = buf.length ? `${current}\n${buf.join('\n')}\n${line}` : `${current}\n${line}`;
|
||||
if (tentative.length > MAX_SECTION_CHARS) {
|
||||
flush();
|
||||
}
|
||||
buf.push(line);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `Generated on ${generatedAt.split('T')[0]} | <https://crowdin.com|View on Crowdin>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {blocks};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dateRange = getLastMonthDateRange();
|
||||
log.info(`Collecting translation statistics for ${dateRange.from} to ${dateRange.to}`);
|
||||
|
||||
// Get project info
|
||||
const projectInfo = await getProjectInfo();
|
||||
log.info(`Project: ${projectInfo.name} (ID: ${projectInfo.id})`);
|
||||
|
||||
// Generate and download report
|
||||
const reportId = await generateReport(dateRange.from, dateRange.to);
|
||||
const reportData = await downloadReport(reportId);
|
||||
|
||||
// Process the data
|
||||
const stats = processReportData(reportData);
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
// Output Slack-formatted message to stdout
|
||||
const slackMessage = formatSlackMessage(
|
||||
stats,
|
||||
projectInfo.name,
|
||||
dateRange.from,
|
||||
dateRange.to,
|
||||
generatedAt
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(slackMessage, null, 2));
|
||||
log.info('All done!');
|
||||
}
|
||||
|
||||
(async () => await main())();
|
||||
|
||||
// Type definitions
|
||||
|
||||
/**
|
||||
* @typedef {Object} CrowdinUser
|
||||
* @property {string} id - User ID
|
||||
* @property {string} username - Username
|
||||
* @property {string} fullName - Full name
|
||||
* @property {string} avatarUrl - Avatar URL
|
||||
* @property {string} joined - Join date
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CrowdinLanguage
|
||||
* @property {string} id - Language ID (e.g., "zh-CN")
|
||||
* @property {string} name - Language name (e.g., "Chinese Simplified")
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CrowdinReportRecord
|
||||
* @property {CrowdinUser} user - User information
|
||||
* @property {CrowdinLanguage[]} languages - Languages the user contributed to
|
||||
* @property {number} translated - Number of strings translated
|
||||
* @property {number} approved - Number of strings approved
|
||||
* @property {number} voted - Number of votes cast
|
||||
* @property {number} positiveVotes - Number of positive votes received
|
||||
* @property {number} negativeVotes - Number of negative votes received
|
||||
* @property {number} winning - Number of winning translations
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CrowdinReportData
|
||||
* @property {CrowdinReportRecord[]} data - Array of report records
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserStats
|
||||
* @property {number} translated - Number of strings translated
|
||||
* @property {number} approved - Number of strings approved
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, Record<string, UserStats>>} ProcessedStats
|
||||
* Language name mapped to username mapped to stats
|
||||
*/
|
||||
@@ -0,0 +1,364 @@
|
||||
import axios from 'axios';
|
||||
import {logger} from '@appium/support';
|
||||
|
||||
const log = logger.getLogger('Contributions');
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
const GITHUB_ORG = 'appium';
|
||||
const MAX_PAGE_LIMIT = 20;
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
const MAX_TITLE_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* Extract date part from ISO string (removes time component)
|
||||
* @param {string} isoString - ISO date string
|
||||
* @returns {string} Date part only (YYYY-MM-DD)
|
||||
*/
|
||||
function extractDatePart(isoString) {
|
||||
return isoString.split('T')[0];
|
||||
}
|
||||
|
||||
// Internal exclusion list - usernames to exclude from reports
|
||||
const INTERNAL_EXCLUSION_LIST = [
|
||||
'dependabot',
|
||||
'dependabot[bot]',
|
||||
'renovate',
|
||||
'renovate[bot]',
|
||||
'appium-ci',
|
||||
'appium-bot',
|
||||
'github-actions[bot]',
|
||||
'pre-commit-ci[bot]',
|
||||
'codecov[bot]',
|
||||
'sonarcloud[bot]',
|
||||
'sonarcloud[bot]',
|
||||
'greenkeeper[bot]',
|
||||
'mergify[bot]',
|
||||
'cla-bot',
|
||||
'claassistant[bot]',
|
||||
'semantic-release-bot',
|
||||
'semantic-release[bot]',
|
||||
'allcontributors[bot]',
|
||||
'imgbot[bot]',
|
||||
'imgbot',
|
||||
'stale[bot]',
|
||||
'stale',
|
||||
'welcome[bot]',
|
||||
'welcome',
|
||||
'invalid-email-address',
|
||||
'invalid-email',
|
||||
'noreply',
|
||||
'noreply@github.com',
|
||||
'jlipps',
|
||||
'mykola-mokhnach',
|
||||
'kazucocoa',
|
||||
'saikrishna321',
|
||||
'srinivasantarget',
|
||||
'eglitise',
|
||||
'navin772',
|
||||
'harsha509',
|
||||
'Delta456',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sort PRs by author login (asc), then by merged date (asc)
|
||||
* @param {GitHubPullRequest[]} pullRequests
|
||||
* @returns {GitHubPullRequest[]}
|
||||
*/
|
||||
function sortPullRequests(pullRequests) {
|
||||
return [...pullRequests].sort((a, b) => {
|
||||
const aAuthor = (a.author?.login || '').toLowerCase();
|
||||
const bAuthor = (b.author?.login || '').toLowerCase();
|
||||
if (aAuthor < bAuthor) {
|
||||
return -1;
|
||||
}
|
||||
if (aAuthor > bAuthor) {
|
||||
return 1;
|
||||
}
|
||||
// If authors are equal, sort by merged date
|
||||
const aMerged = a.merged_at ? new Date(a.merged_at).getTime() : 0;
|
||||
const bMerged = b.merged_at ? new Date(b.merged_at).getTime() : 0;
|
||||
return aMerged - bMerged;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date range for the last calendar month
|
||||
* @returns {{from: string, to: string}}
|
||||
*/
|
||||
function getLastMonthDateRange() {
|
||||
const now = new Date();
|
||||
// First day of last month at 00:00:00 UTC
|
||||
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0));
|
||||
// Last day of last month at 23:59:59 UTC
|
||||
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0, 23, 59, 59));
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make request to GitHub API (authenticated or unauthenticated)
|
||||
* @param {string} endpoint - API endpoint
|
||||
* @param {string|null} token - GitHub token (optional)
|
||||
* @returns {Promise<{data: any, headers: any}>}
|
||||
*/
|
||||
async function makeGitHubRequest(endpoint, token) {
|
||||
const url = `${GITHUB_API_BASE}${endpoint}`;
|
||||
const headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'appium-contrib-stats',
|
||||
};
|
||||
|
||||
// Add authorization header only if token is provided
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {data: response.data, headers: response.headers};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged pull requests from the Appium organization for the specified date range using GitHub search API
|
||||
* @param {Object} dateRange - Date range object with from and to properties
|
||||
* @param {string} dateRange.from - Start date in ISO format
|
||||
* @param {string} dateRange.to - End date in ISO format
|
||||
* @param {string|null} [token] - GitHub token (optional)
|
||||
* @returns {Promise<GitHubPullRequest[]>} Array of pull request objects
|
||||
*/
|
||||
async function getMergedPullRequestsFromSearch(dateRange, token) {
|
||||
/** @type {GitHubPullRequest[]} */
|
||||
const pullRequests = [];
|
||||
let page = 1;
|
||||
const perPage = ITEMS_PER_PAGE;
|
||||
|
||||
// Convert ISO dates to GitHub's expected format (YYYY-MM-DD)
|
||||
const fromDate = extractDatePart(dateRange.from);
|
||||
const toDate = extractDatePart(dateRange.to);
|
||||
|
||||
// Build search query for merged pull requests in the appium organization
|
||||
// Using GitHub's search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests
|
||||
const excludedAuthors = INTERNAL_EXCLUSION_LIST.map((author) => `-author:${author}`).join(' ');
|
||||
const searchQuery = `org:${GITHUB_ORG} is:pr is:merged merged:${fromDate}..${toDate} ${excludedAuthors}`;
|
||||
while (true) {
|
||||
// Rely on local sorting; omit API-side sort/order for simplicity
|
||||
const endpoint = `/search/issues?q=${encodeURIComponent(searchQuery)}&page=${page}&per_page=${perPage}`;
|
||||
const {data} = await makeGitHubRequest(endpoint, token);
|
||||
if (!data.items?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Process the search results (pull requests)
|
||||
pullRequests.push(...data.items.map((pr) => ({
|
||||
sha: pr.merge_commit_sha,
|
||||
author: pr.user,
|
||||
created_at: pr.created_at,
|
||||
merged_at: pr.merged_at || pr.closed_at, // Use closed_at as fallback if merged_at is null
|
||||
commit: {
|
||||
message: pr.title, // Use PR title as the commit message
|
||||
},
|
||||
html_url: pr.html_url,
|
||||
repository: pr.repository_url.split('/').pop(), // Extract repo name from URL
|
||||
})));
|
||||
|
||||
page++;
|
||||
// Safety check
|
||||
if (page > MAX_PAGE_LIMIT) {
|
||||
log.warn(`Reached maximum page limit (${MAX_PAGE_LIMIT}) for PR search`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pullRequests;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format GitHub pull request data as Slack message blocks
|
||||
* @param {GitHubPullRequest[]} pullRequests - Array of pull request objects
|
||||
* @param {string} from - Start date in ISO 8601 format
|
||||
* @param {string} to - End date in ISO 8601 format
|
||||
* @param {string} generatedAt - Generation timestamp in ISO 8601 format
|
||||
* @returns {object} Slack message payload with blocks
|
||||
*/
|
||||
function formatSlackMessage(pullRequests, from, to, generatedAt) {
|
||||
const monthName = new Date(from).toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||
const fromDate = extractDatePart(from);
|
||||
const toDate = extractDatePart(to);
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: `🚀 GitHub Contribution Report for ${monthName}`,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Organization:* @${GITHUB_ORG}\n*Period:* ${fromDate} to ${toDate}\n*Total Merged Pull Requests:* ${pullRequests.length}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: '❌ No merged pull requests found for this period.',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create simple markdown rows with links
|
||||
const rowLines = pullRequests.map((pr, index) => {
|
||||
// Format created date (when PR was created)
|
||||
const createdDate = new Date(pr.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
// Format merged date (when PR was merged)
|
||||
const mergedDate = new Date(pr.merged_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const authorName = pr.author?.login || 'Unknown';
|
||||
const authorUrl = pr.author?.html_url || `https://github.com/${authorName}`;
|
||||
|
||||
// Use pull request title directly
|
||||
let prTitle = pr.commit.message; // This is the PR title from our data mapping
|
||||
if (prTitle.length > MAX_TITLE_LENGTH) {
|
||||
prTitle = prTitle.substring(0, MAX_TITLE_LENGTH - 1) + '…';
|
||||
}
|
||||
|
||||
// Construct repository URL
|
||||
const repoUrl = `https://github.com/${GITHUB_ORG}/${pr.repository}`;
|
||||
|
||||
// Format as simple markdown row with Slack-formatted links
|
||||
// Column order changed to: index • author • title • repo • dates
|
||||
return `${index + 1} • <${authorUrl}|${authorName}> • <${pr.html_url}|${prTitle}> • <${repoUrl}|${pr.repository}> • Created: ${createdDate} • Merged: ${mergedDate}`;
|
||||
});
|
||||
|
||||
// Slack section text has a 3000 character limit. Keep under ~2900 to be safe.
|
||||
const MAX_SECTION_CHARS = 2900;
|
||||
let currentLines = [];
|
||||
let currentLen = 0;
|
||||
|
||||
const flushSection = () => {
|
||||
if (currentLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const content = currentLines.join('\n');
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {type: 'mrkdwn', text: content},
|
||||
});
|
||||
currentLines = [];
|
||||
currentLen = 0;
|
||||
};
|
||||
|
||||
for (const line of rowLines) {
|
||||
const addLen = line.length + 1; // plus newline
|
||||
if (currentLen + addLen > MAX_SECTION_CHARS) {
|
||||
flushSection();
|
||||
}
|
||||
currentLines.push(line);
|
||||
currentLen += addLen;
|
||||
}
|
||||
flushSection();
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `Generated on ${extractDatePart(generatedAt)} | <https://github.com/${GITHUB_ORG}|View Organization>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {blocks};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!token) {
|
||||
log.warn('GITHUB_TOKEN not provided - using unauthenticated requests');
|
||||
}
|
||||
|
||||
const dateRange = getLastMonthDateRange();
|
||||
log.info(`Collecting GitHub contribution statistics for ${dateRange.from} to ${dateRange.to}`);
|
||||
|
||||
// Get merged pull requests using GitHub search API
|
||||
log.info('Fetching merged pull requests using GitHub search API...');
|
||||
const allPullRequests = await getMergedPullRequestsFromSearch(dateRange, token);
|
||||
log.info(`Found ${allPullRequests.length} merged pull requests across all repositories`);
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
const sortedPullRequests = sortPullRequests(allPullRequests);
|
||||
|
||||
// Output Slack-formatted message to stdout
|
||||
const slackMessage = formatSlackMessage(
|
||||
sortedPullRequests,
|
||||
dateRange.from,
|
||||
dateRange.to,
|
||||
generatedAt
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(slackMessage, null, 2));
|
||||
log.info('All done!');
|
||||
}
|
||||
|
||||
(async () => await main())();
|
||||
|
||||
// Type definitions
|
||||
|
||||
/**
|
||||
* @typedef {Object} GitHubUser
|
||||
* @property {string} login - Username
|
||||
* @property {number} id - User ID
|
||||
* @property {string} html_url - Profile URL
|
||||
* @property {string} avatar_url - Avatar URL
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GitHubCommitAuthor
|
||||
* @property {string} name - Author name
|
||||
* @property {string} email - Author email
|
||||
* @property {string} date - Commit date
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GitHubPullRequest
|
||||
* @property {string} sha - Merge commit SHA
|
||||
* @property {GitHubUser} author - Author user object
|
||||
* @property {GitHubUser} committer - Committer user object
|
||||
* @property {Object} commit - Commit details
|
||||
* @property {GitHubCommitAuthor} commit.author - Commit author details
|
||||
* @property {GitHubCommitAuthor} commit.committer - Commit committer details
|
||||
* @property {string} commit.message - Pull request title
|
||||
* @property {string} html_url - Pull request URL
|
||||
* @property {string} repository - Repository name
|
||||
*/
|
||||
Reference in New Issue
Block a user