Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/publish.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ name: Release

on:
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (no publishing, tagging, or git changes)'
required: true
type: boolean
default: true
dist_tag:
description: 'npm dist-tag to publish under. Set to "beta" to release this run''s branch as a beta prerelease; leave empty for a normal release.'
required: false
type: string
default: ''
push:
branches: [ master ]

Expand Down Expand Up @@ -29,7 +40,16 @@ jobs:
name: Linter
- run: npm run test
name: Unit Test
- run: npx semantic-release
- run: |
dry_run_flag=""
if [ "$DRY_RUN" = "true" ]; then
dry_run_flag="--dry-run"
fi
npx semantic-release $dry_run_flag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
# Only opts release.config.mjs into treating this run's branch as the beta channel
# when dist_tag was explicitly set to "beta" - see release.config.mjs's betaBranch.
BETA_BRANCH_NAME: ${{ inputs.dist_tag == 'beta' && github.ref_name || '' }}
name: Release
8 changes: 4 additions & 4 deletions lib/commands/geolocation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import path from 'node:path';

import {fs, tempDir} from '@appium/support';
import type {Location} from '@appium/types';
import {asyncmap} from 'asyncbox';
import {SETTINGS_HELPER_ID} from 'io.appium.settings';

import type {AndroidDriver} from '../driver.js';
import {getThirdPartyPackages} from './app-management.js';
import type {GeoLocation} from './types.js';

// The value close to zero, but not zero, is needed
// to trick JSON generation and send a float value instead of an integer,
Expand All @@ -22,7 +22,7 @@ const MOCK_APP_IDS_STORE = '/data/local/tmp/mock_apps.json';
* @param location The geolocation object containing latitude, longitude, and altitude.
* @returns Promise that resolves to the current geolocation after setting it.
*/
export async function setGeoLocation(this: AndroidDriver, location: Location): Promise<Location> {
export async function setGeoLocation(this: AndroidDriver, location: GeoLocation): Promise<GeoLocation> {
await this.settingsApp.setGeoLocation(location, this.isEmulator());
try {
return await this.getGeoLocation();
Expand Down Expand Up @@ -98,7 +98,7 @@ export async function mobileRefreshGpsCache(this: AndroidDriver, timeoutMs?: num
*
* @returns Promise that resolves to the current geolocation object.
*/
export async function getGeoLocation(this: AndroidDriver): Promise<Location> {
export async function getGeoLocation(this: AndroidDriver): Promise<GeoLocation> {
const {latitude, longitude, altitude} = await this.settingsApp.getGeoLocation();
return {
latitude: parseFloat(String(latitude)) || GEO_EPSILON,
Expand All @@ -112,7 +112,7 @@ export async function getGeoLocation(this: AndroidDriver): Promise<Location> {
*
* @returns Promise that resolves to the current geolocation object.
*/
export async function mobileGetGeolocation(this: AndroidDriver): Promise<Location> {
export async function mobileGetGeolocation(this: AndroidDriver): Promise<GeoLocation> {
return await this.getGeoLocation();
}

Expand Down
10 changes: 10 additions & 0 deletions lib/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,16 @@ export type PerformanceDataType = 'batteryinfo' | 'cpuinfo' | 'memoryinfo' | 'ne
*/
export type NfcAction = 'enable' | 'disable';

/**
* A device geolocation. `altitude` matches `@appium/types`'s removed `Location` interface
* (it was dropped from Appium 4 along with the legacy geolocation endpoint).
*/
export interface GeoLocation {
latitude: number;
longitude: number;
altitude?: number;
}

/**
* Represents a device locale with language, country, and optional script.
*/
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"test": "node --enable-source-maps --experimental-test-module-mocks --test --test-force-exit --test-timeout=60000 \"./build/test/unit/**/*.spec.js\""
},
"dependencies": {
"@appium/support": "^7.2.6",
"@appium/support": "^8.0.0-beta.1",
"appium-adb": "^16.0.4",
"appium-chromedriver": "^9.0.15",
"asyncbox": "^6.4.3",
Expand All @@ -65,10 +65,10 @@
"ws": "^8.0.0"
},
"devDependencies": {
"@appium/oxc-config": "^1.1.0",
"@appium/semantic-release-config": "^1.1.0",
"@appium/tsconfig": "^1.2.0",
"@appium/types": "^1.0.0-rc.1",
"@appium/oxc-config": "^2.0.0-beta.0",
"@appium/semantic-release-config": "^2.0.0-beta.0",
"@appium/tsconfig": "^2.0.0-beta.0",
"@appium/types": "^2.0.0-beta.1",
"@types/node": "^26.0.0",
"@types/portscanner": "^2.1.1",
"@types/semver": "^7.5.0",
Expand All @@ -77,10 +77,10 @@
"sinon": "^22.0.0"
},
"peerDependencies": {
"appium": "^3.0.0-rc.2"
"appium": "^4.0.0-beta.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"node": "^22.22.2 || ^24.15.0 || >=26.0.0",
"npm": ">=10"
}
}
8 changes: 7 additions & 1 deletion release.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import releaseConfig from '@appium/semantic-release-config';

export default releaseConfig();
// Set only when the pipeline explicitly opts into a beta run (BETA_BRANCH_NAME set by
// .github/workflows/publish.js.yml's dist_tag input). Left undefined otherwise, so releaseConfig
// leaves `branches` unset and semantic-release's own default branches list applies - which
// already treats a branch literally named `beta` as a prerelease channel.
const betaBranch = process.env.BETA_BRANCH_NAME || undefined;

export default releaseConfig({betaBranch});
108 changes: 75 additions & 33 deletions test/unit/commands/file-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
import assert from 'node:assert/strict';
import {execFile} from 'node:child_process';
import {describe, it, beforeEach, afterEach} from 'node:test';
import type {TestContext} from 'node:test';
import {promisify} from 'node:util';

import * as support from '@appium/support';
import {ADB} from 'appium-adb';
import sinon from 'sinon';

import type * as FileActions from '../../../lib/commands/file-actions.js';
import {AndroidDriver} from '../../../lib/driver.js';

const FILE_ACTIONS_PATH = '../../../lib/commands/file-actions.js';

let driver: AndroidDriver;
const sandbox = sinon.createSandbox();

let importCounter = 0;
function importFresh(specifier: string) {
return import(`${specifier}?mock=${importCounter++}`);
}

// `@appium/support`'s `tempDir`/`util` namespaces are real ESM module namespace objects, which
// are frozen and can't be stubbed in place (unlike `fs`, which stays a plain object) - so
// `tempDir.path`/`util.toInMemoryBase64` overrides are swapped in via module mocking instead,
// and file-actions.js is re-imported so it resolves the mocked `@appium/support`.
//
// `support` (an `import * as` namespace) carries a `default` key alongside its named exports;
// spreading it into `namedExports` as-is trips a Node 22 module-mock bug ("Unexpected token
// 'default'"), fixed by Node 24 - so `default` is stripped out here.
const {default: _supportDefault, ...supportNamedExports} = support;

async function mockFileActions(
t: TestContext,
overrides: {tempDirPath?: sinon.SinonStub; toInMemoryBase64?: sinon.SinonStub} = {},
) {
t.mock.module('@appium/support', {
namedExports: {
...supportNamedExports,
tempDir: overrides.tempDirPath ? {...support.tempDir, path: overrides.tempDirPath} : support.tempDir,
util: overrides.toInMemoryBase64 ? {...support.util, toInMemoryBase64: overrides.toInMemoryBase64} : support.util,
},
});
return (await importFresh(FILE_ACTIONS_PATH)) as typeof FileActions;
}

describe('File Actions', function () {
beforeEach(function () {
driver = new AndroidDriver();
Expand All @@ -22,31 +55,33 @@ describe('File Actions', function () {
});

describe('pullFile', function () {
it('should be able to pull file from device', async function () {
it('should be able to pull file from device', async function (t) {
const localFile = 'local/tmp_file';
sandbox.stub(support.tempDir, 'path').resolves(localFile);
const tempDirPath = sandbox.stub().resolves(localFile);
const toInMemoryBase64 = sandbox.stub().withArgs(localFile).resolves(Buffer.from('YXBwaXVt', 'utf8'));
const {pullFile} = await mockFileActions(t, {tempDirPath, toInMemoryBase64});
const pullStub1 = sandbox.stub(driver.adb, 'pull');
sandbox.stub(support.util, 'toInMemoryBase64').withArgs(localFile).resolves(Buffer.from('YXBwaXVt', 'utf8'));
sandbox.stub(support.fs, 'exists').withArgs(localFile).resolves(true);
const unlinkStub4 = sandbox.stub(support.fs, 'unlink');
assert.strictEqual(await driver.pullFile('remote_path'), 'YXBwaXVt');
assert.strictEqual(await pullFile.call(driver, 'remote_path'), 'YXBwaXVt');
assert.strictEqual(pullStub1.calledWithExactly('remote_path', localFile), true);
assert.strictEqual(unlinkStub4.calledWithExactly(localFile), true);
});

it('should be able to pull file located in application container from the device', async function () {
it('should be able to pull file located in application container from the device', async function (t) {
const localFile = 'local/tmp_file';
const packageId = 'com.myapp';
const remotePath = 'path/in/container';
const tmpPath = '/data/local/tmp/appium-pull-test';
sandbox.stub(support.tempDir, 'path').resolves(localFile);
const tempDirPath = sandbox.stub().resolves(localFile);
const toInMemoryBase64 = sandbox.stub().withArgs(localFile).resolves(Buffer.from('YXBwaXVt', 'utf8'));
const {pullFile} = await mockFileActions(t, {tempDirPath, toInMemoryBase64});
const pullStub = sandbox.stub(driver.adb, 'pull');
const shellStub2 = sandbox.stub(driver.adb, 'shell');
shellStub2.withArgs(['mktemp', '/data/local/tmp/appium-pull-XXXXXX']).resolves(tmpPath);
sandbox.stub(support.util, 'toInMemoryBase64').withArgs(localFile).resolves(Buffer.from('YXBwaXVt', 'utf8'));
sandbox.stub(support.fs, 'exists').withArgs(localFile).resolves(true);
const unlinkStub3 = sandbox.stub(support.fs, 'unlink');
assert.strictEqual(await driver.pullFile(`@${packageId}/${remotePath}`), 'YXBwaXVt');
assert.strictEqual(await pullFile.call(driver, `@${packageId}/${remotePath}`), 'YXBwaXVt');
assert.strictEqual(pullStub.calledWithExactly(tmpPath, localFile), true);
assert.strictEqual(
shellStub2.calledWithExactly([
Expand All @@ -64,13 +99,14 @@ describe('File Actions', function () {
});

for (const failure of ['read container', 'pull', 'encode', 'unlink'] as const) {
it(`cleans up the remote temporary file when ${failure} fails`, async function () {
it(`cleans up the remote temporary file when ${failure} fails`, async function (t) {
const localFile = 'local/tmp_file';
const tmpPath = '/data/local/tmp/appium-pull-test';
sandbox.stub(support.tempDir, 'path').resolves(localFile);
const tempDirPath = sandbox.stub().resolves(localFile);
const encode = sandbox.stub().resolves(Buffer.from('YXBwaXVt'));
const {pullFile} = await mockFileActions(t, {tempDirPath, toInMemoryBase64: encode});
sandbox.stub(support.fs, 'exists').resolves(true);
const unlink = sandbox.stub(support.fs, 'unlink').resolves();
const encode = sandbox.stub(support.util, 'toInMemoryBase64').resolves(Buffer.from('YXBwaXVt'));
const pull = sandbox.stub(driver.adb, 'pull').resolves();
const shell = sandbox.stub(driver.adb, 'shell').resolves('');
shell.onFirstCall().resolves(tmpPath);
Expand All @@ -84,7 +120,7 @@ describe('File Actions', function () {
} else {
unlink.rejects(error);
}
await assert.rejects(driver.pullFile('@com.myapp/files/test'), /transfer failed/);
await assert.rejects(pullFile.call(driver, '@com.myapp/files/test'), /transfer failed/);
assert.deepStrictEqual(shell.lastCall.args, [['rm', '-f', tmpPath]]);
if (failure === 'read container') {
assert.strictEqual(pull.called, false);
Expand All @@ -99,14 +135,15 @@ describe('File Actions', function () {
const remotePath = `@${pkg}/${relativePath}`;
const fullPath = `/data/data/${pkg}/${relativePath}`;

it('preserves the complete path when pulling', async function () {
sandbox.stub(support.tempDir, 'path').resolves('local-file');
it('preserves the complete path when pulling', async function (t) {
const tempDirPath = sandbox.stub().resolves('local-file');
const toInMemoryBase64 = sandbox.stub().resolves(Buffer.from('YXBwaXVt'));
const {pullFile} = await mockFileActions(t, {tempDirPath, toInMemoryBase64});
sandbox.stub(support.fs, 'exists').resolves(false);
sandbox.stub(support.util, 'toInMemoryBase64').resolves(Buffer.from('YXBwaXVt'));
sandbox.stub(driver.adb, 'pull').resolves();
const shell = sandbox.stub(driver.adb, 'shell').resolves('');
shell.onFirstCall().resolves('/data/local/tmp/appium-pull-test');
await driver.pullFile(remotePath);
await pullFile.call(driver, remotePath);
assert.deepStrictEqual(shell.secondCall.args[0], [
'run-as',
pkg,
Expand All @@ -117,13 +154,14 @@ describe('File Actions', function () {
]);
});

it('preserves the complete path when pushing', async function () {
sandbox.stub(support.tempDir, 'path').resolves('local-file');
it('preserves the complete path when pushing', async function (t) {
const tempDirPath = sandbox.stub().resolves('local-file');
const {pushFile} = await mockFileActions(t, {tempDirPath});
sandbox.stub(support.fs, 'writeFile').resolves();
sandbox.stub(support.fs, 'exists').resolves(false);
const push = sandbox.stub(driver.adb, 'push').resolves();
const shell = sandbox.stub(driver.adb, 'shell').resolves('');
await driver.pushFile(remotePath, 'YXBwaXVt');
await pushFile.call(driver, remotePath, 'YXBwaXVt');
assert.strictEqual(push.calledWithExactly('local-file', '/data/local/tmp/line\nbreak.txt'), true);
assert.strictEqual(shell.calledWithExactly(['run-as', pkg, `touch '${fullPath}'`]), true);
});
Expand All @@ -142,34 +180,36 @@ describe('File Actions', function () {
});

describe('pushFile', function () {
it('should be able to push file to device', async function () {
it('should be able to push file to device', async function (t) {
const localFile = 'local/tmp_file';
const content = 'appium';
sandbox.stub(support.tempDir, 'path').resolves(localFile);
const tempDirPath = sandbox.stub().resolves(localFile);
const {pushFile} = await mockFileActions(t, {tempDirPath});
const pushStub1 = sandbox.stub(driver.adb, 'push');
sandbox.stub(driver.adb, 'shell');
const writeFileStub1 = sandbox.stub(support.fs, 'writeFile');
sandbox.stub(support.fs, 'exists').withArgs(localFile).resolves(true);
const unlinkStub1 = sandbox.stub(support.fs, 'unlink');
await driver.pushFile('remote_path', 'YXBwaXVt');
await pushFile.call(driver, 'remote_path', 'YXBwaXVt');
assert.strictEqual(writeFileStub1.calledWithExactly(localFile, content, 'binary'), true);
assert.strictEqual(unlinkStub1.calledWithExactly(localFile), true);
assert.strictEqual(pushStub1.calledWithExactly(localFile, 'remote_path'), true);
});

it('should be able to push file located in application container to the device', async function () {
it('should be able to push file located in application container to the device', async function (t) {
const localFile = 'local/tmp_file';
const content = 'appium';
const packageId = 'com.myapp';
const remotePath = 'path/in/container';
const tmpPath = '/data/local/tmp/container';
sandbox.stub(support.tempDir, 'path').resolves(localFile);
const tempDirPath = sandbox.stub().resolves(localFile);
const {pushFile} = await mockFileActions(t, {tempDirPath});
const pushStub2 = sandbox.stub(driver.adb, 'push');
const writeFileStub = sandbox.stub(support.fs, 'writeFile');
sandbox.stub(support.fs, 'exists').withArgs(localFile).resolves(true);
const unlinkStub2 = sandbox.stub(support.fs, 'unlink');
const shellStub = sandbox.stub(driver.adb, 'shell');
await driver.pushFile(`@${packageId}/${remotePath}`, 'YXBwaXVt');
await pushFile.call(driver, `@${packageId}/${remotePath}`, 'YXBwaXVt');
assert.strictEqual(writeFileStub.calledWithExactly(localFile, content, 'binary'), true);
assert.strictEqual(pushStub2.calledWithExactly(localFile, tmpPath), true);
assert.strictEqual(
Expand Down Expand Up @@ -214,15 +254,16 @@ describe('File action argument preservation', {skip: process.platform === 'win32
});

for (const apiLevel of [26, 28]) {
it(`preserves the media scan URI on API ${apiLevel}`, async function () {
it(`preserves the media scan URI on API ${apiLevel}`, async function (t) {
const target = `/data/local/tmp/${fileName}\nnext.txt`;
sandbox.stub(support.tempDir, 'path').resolves('local-file');
const tempDirPath = sandbox.stub().resolves('local-file');
const {pushFile} = await mockFileActions(t, {tempDirPath});
sandbox.stub(support.fs, 'exists').resolves(false);
sandbox.stub(support.fs, 'writeFile').resolves();
sandbox.stub(driver.adb, 'push').resolves();
sandbox.stub(driver.adb, 'getApiLevel').resolves(apiLevel);
const shell = sandbox.stub(driver.adb, 'shell').resolves('');
await driver.pushFile(target, 'YXBwaXVt');
await pushFile.call(driver, target, 'YXBwaXVt');
assert.deepStrictEqual(await parseDeviceCommand(shell.firstCall.args[0]), [
'am',
'broadcast',
Expand All @@ -235,21 +276,22 @@ describe('File action argument preservation', {skip: process.platform === 'win32
}

for (const operation of ['pull', 'push'] as const) {
it(`preserves package names and paths through ${operation} and cleanup`, async function () {
sandbox.stub(support.tempDir, 'path').resolves('local-file');
it(`preserves package names and paths through ${operation} and cleanup`, async function (t) {
const tempDirPath = sandbox.stub().resolves('local-file');
const toInMemoryBase64 = sandbox.stub().resolves(Buffer.from('YXBwaXVt'));
const {pullFile, pushFile} = await mockFileActions(t, {tempDirPath, toInMemoryBase64});
sandbox.stub(support.fs, 'exists').resolves(true);
sandbox.stub(support.fs, 'unlink').resolves();
sandbox.stub(support.fs, 'writeFile').resolves();
sandbox.stub(support.util, 'toInMemoryBase64').resolves(Buffer.from('YXBwaXVt'));
const pull = sandbox.stub(driver.adb, 'pull').resolves();
const push = sandbox.stub(driver.adb, 'push').resolves();
const shell = sandbox.stub(driver.adb, 'shell').resolves('');
if (operation === 'pull') {
shell.onFirstCall().resolves(tempPath);
await driver.pullFile(remotePath);
await pullFile.call(driver, remotePath);
assert.strictEqual(pull.calledWithExactly(tempPath, 'local-file'), true);
} else {
await driver.pushFile(remotePath, 'YXBwaXVt');
await pushFile.call(driver, remotePath, 'YXBwaXVt');
assert.strictEqual(push.calledWithExactly('local-file', tempPath), true);
}
const commands = await Promise.all(
Expand Down
Loading