diff --git a/.github/workflows/publish.js.yml b/.github/workflows/publish.js.yml index 8c902d12..077450fe 100644 --- a/.github/workflows/publish.js.yml +++ b/.github/workflows/publish.js.yml @@ -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 ] @@ -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 diff --git a/lib/commands/geolocation.ts b/lib/commands/geolocation.ts index 8fbdf214..ff458de0 100644 --- a/lib/commands/geolocation.ts +++ b/lib/commands/geolocation.ts @@ -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, @@ -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 { +export async function setGeoLocation(this: AndroidDriver, location: GeoLocation): Promise { await this.settingsApp.setGeoLocation(location, this.isEmulator()); try { return await this.getGeoLocation(); @@ -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 { +export async function getGeoLocation(this: AndroidDriver): Promise { const {latitude, longitude, altitude} = await this.settingsApp.getGeoLocation(); return { latitude: parseFloat(String(latitude)) || GEO_EPSILON, @@ -112,7 +112,7 @@ export async function getGeoLocation(this: AndroidDriver): Promise { * * @returns Promise that resolves to the current geolocation object. */ -export async function mobileGetGeolocation(this: AndroidDriver): Promise { +export async function mobileGetGeolocation(this: AndroidDriver): Promise { return await this.getGeoLocation(); } diff --git a/lib/commands/types.ts b/lib/commands/types.ts index 06bcab54..bb413d25 100644 --- a/lib/commands/types.ts +++ b/lib/commands/types.ts @@ -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. */ diff --git a/package.json b/package.json index c1742117..2e25017c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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" } } diff --git a/release.config.mjs b/release.config.mjs index 83f3400b..deed56a9 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -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}); diff --git a/test/unit/commands/file-actions.spec.ts b/test/unit/commands/file-actions.spec.ts index 3e626833..48719ca3 100644 --- a/test/unit/commands/file-actions.spec.ts +++ b/test/unit/commands/file-actions.spec.ts @@ -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(); @@ -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([ @@ -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); @@ -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); @@ -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, @@ -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); }); @@ -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( @@ -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', @@ -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(