Repository: appium/appium
Pull request #20866 (2024-12-28 09:39:09)

feat(appium): Update entry point resolution logic for ESM extensions
## Proposed changes

ESM packages have completely different logic for the entry point resolution in comparison to CJS ones. Check https://nodejs.org/api/packages.html#package-entry-points for more details

## Types of changes

- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation Update (if none of the other choices apply)



Patch:
diff --git a/packages/appium/lib/extension/extension-config.js b/packages/appium/lib/extension/extension-config.js
index e90c82370..098f7b266 100644
--- a/packages/appium/lib/extension/extension-config.js
+++ b/packages/appium/lib/extension/extension-config.js
@@ -14,6 +14,7 @@ import {
 } from '../schema/schema';
 import { pathToFileURL } from 'url';
 
+const DEFAULT_ENTRY_POINT = 'index.js';
 /**
  * "npm" install type
  * Used when extension was installed by npm package name
@@ -567,25 +568,39 @@ export class ExtensionConfig {
     const {mainClass} = this.installedExtensions[extName];
     const moduleRoot = this.getInstallPath(extName);
     const packageJsonPath = path.join(moduleRoot, 'package.json');
-    let entryPointPath;
+    let extensionManifest;
     try {
-      entryPointPath = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')).main ?? 'index.js';
+      extensionManifest = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
     } catch (e) {
       throw new ReferenceError(
-        `Could not find a ${this.extensionType} installed at ${moduleRoot}: ${e.message}`
+        `Could not read the ${this.extensionType} manifest at ${packageJsonPath}: ${e.message}`
       );
     }
-    if (entryPointPath) {
-      entryPointPath = path.resolve(moduleRoot, entryPointPath);
-    } else {
-      throw new ReferenceError(`Cannot find a valid ${this.extensionType} main entry point in '${packageJsonPath}'`);
+    /** @type {string | undefined} */
+    let entryPointRelativePath;
+    try {
+      if (extensionManifest.type === 'module' && extensionManifest.exports) {
+        entryPointRelativePath = resolveEsmEntryPoint(extensionManifest.exports);
+      }
+      entryPointRelativePath = entryPointRelativePath ?? extensionManifest.main ?? DEFAULT_ENTRY_POINT;
+    } catch (e) {
+      throw new ReferenceError(
+        `Could not find the ${this.extensionType} installed at ${moduleRoot}: ${e.message}`
+      );
+    }
+    const entryPointFullPath = path.resolve(moduleRoot, /** @type {string} */ (entryPointRelativePath));
+    if (!await fs.exists(entryPointFullPath)) {
+      throw new ReferenceError(
+        `Cannot find a valid ${this.extensionType} main entry point in '${packageJsonPath}'. ` +
+        `Assumed entry point: '${entryPointFullPath}'`
+      );
     }
     // note: this will only reload the entry point
-    if (process.env.APPIUM_RELOAD_EXTENSIONS && require.cache[entryPointPath]) {
-      log.debug(`Removing ${entryPointPath} from require cache`);
-      delete require.cache[entryPointPath];
+    if (process.env.APPIUM_RELOAD_EXTENSIONS && require.cache[entryPointFullPath]) {
+      log.debug(`Removing ${entryPointFullPath} from require cache`);
+      delete require.cache[entryPointFullPath];
     }
-    return [entryPointPath, mainClass];
+    return [entryPointFullPath, mainClass];
   }
 
   /**
@@ -674,6 +689,27 @@ export class ExtensionConfig {
   }
 }
 
+/**
+ * https://nodejs.org/api/packages.html#package-entry-points
+ *
+ * @param {any} exportsValue
+ * @returns {string | undefined}
+ */
+export function resolveEsmEntryPoint(exportsValue) {
+  if (_.isString(exportsValue) && exportsValue) {
+    return exportsValue;
+  }
+  if (!_.isPlainObject(exportsValue)) {
+    return;
+  }
+
+  for (const key of ['.', 'import']) {
+    if (exportsValue[key]) {
+      return resolveEsmEntryPoint(exportsValue[key]);
+    }
+  }
+}
+
 /**
  * An issue with the {@linkcode ExtManifest} for a particular extension.
  *
diff --git a/packages/fake-driver/lib/driver.js b/packages/fake-driver/lib/driver.js
index 4f9f379b3..ba8588d3a 100644
--- a/packages/fake-driver/lib/driver.js
+++ b/packages/fake-driver/lib/driver.js
@@ -153,6 +153,20 @@ export class FakeDriver extends BaseDriver {
     return await super.deleteSession(sessionId);
   }
 
+  /**
+   * @returns {Promise<string>}
+   */
+  async getWindowHandle() {
+    return '1';
+  }
+
+  /**
+   * @returns {Promise<string[]>}
+   */
+  async getWindowHandles() {
+    return ['1'];
+  }
+
   get driverData() {
     return {
       isUnique: !!this.caps.uniqueApp,


Test patch:
diff --git a/packages/appium/test/unit/extension/extension-config.spec.js b/packages/appium/test/unit/extension/extension-config.spec.js
index 5b43a159b..a5a8c9298 100644
--- a/packages/appium/test/unit/extension/extension-config.spec.js
+++ b/packages/appium/test/unit/extension/extension-config.spec.js
@@ -3,6 +3,7 @@ import path from 'path';
 import {version as APPIUM_VER} from '../../../package.json';
 import {FAKE_DRIVER_DIR, PROJECT_ROOT, rewiremock} from '../../helpers';
 import {initMocks} from './mocks';
+import {resolveEsmEntryPoint} from '../../../lib/extension/extension-config';
 
 describe('ExtensionConfig', function () {
   /** @type {import('sinon').SinonSandbox} */
@@ -45,7 +46,31 @@ describe('ExtensionConfig', function () {
     process.removeAllListeners('exit');
   });
 
-  describe('constructor', function () {});
+  describe('ESM module resolution', function () {
+    it('resolves ESM entry point with simple export', function () {
+      resolveEsmEntryPoint('./index.js').should.eql('./index.js');
+    });
+
+    it('resolves ESM entry point with dot export', function () {
+      resolveEsmEntryPoint({
+        '.': './index.js'
+      }).should.eql('./index.js');
+    });
+
+    it('resolves ESM entry point with import export', function () {
+      resolveEsmEntryPoint({
+        import: './index.js'
+      }).should.eql('./index.js');
+    });
+
+    it('resolves ESM entry point with complex import export', function () {
+      resolveEsmEntryPoint({
+        '.': {
+          import: './index.js'
+        }
+      }).should.eql('./index.js');
+    });
+  });
 
   describe('instance method', function () {
     /** @type {import('appium/lib/extension/extension-config').ExtensionConfig<DriverType>} */
diff --git a/packages/appium/test/unit/extension/mocks.js b/packages/appium/test/unit/extension/mocks.js
index 8de387901..0b0771a2f 100644
--- a/packages/appium/test/unit/extension/mocks.js
+++ b/packages/appium/test/unit/extension/mocks.js
@@ -35,6 +35,7 @@ export function initMocks(sandbox = createSandbox()) {
       findRoot: /** @type {MockAppiumSupportFs['findRoot']} */ (
         sandbox.stub().returns(path.join(__dirname, '..', '..', '..'))
       ),
+      exists: /** @type {MockAppiumSupportFs['exists']} */ (sandbox.stub().resolves(true)),
     },
     env: {
       resolveAppiumHome: /** @type {MockAppiumSupportEnv['resolveAppiumHome']} */ (
@@ -172,6 +173,7 @@ export function initMocks(sandbox = createSandbox()) {
  * @property {SinonStubbedMember<SupportFs['mkdirp']>} mkdirp
  * @property {SinonStubbedMember<SupportFs['readPackageJsonFrom']>} readPackageJsonFrom
  * @property {SinonStubbedMember<SupportFs['findRoot']>} findRoot
+ * @property {SinonStubbedMember<SupportFs['exists']>} exists
  */
 
 /**