Cypress - 從類別中動態載入 plugin

#cypress#javascript#typescript

By Benny Yen at

近期因為測試需求需要讀取 Excel,而要在 cypress 中執行 Node 必須利用 cy.task 做為橋樑,有點像是 electronipcMain,在網頁上發送事件來執行 Node

Cypress 版本 10 之後,捨棄掉了在 plugin/index.ts 設定 task event 的方式,取而代之的是在新的 cypress.config.ts 中建立 task event。

import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    // ...
    setupNodeEvents(on, config) {
      on("task", {
        // 設定 task event
      });

      return config;
    },
  },
});

前面提到測試需要讀取 Excel,因此寫了專門處理 Excel 的類別(class),我想要將這個類別所有的 methods 都提出來建立成個別的 event,然而把 method 一個一個手動寫進 event 明顯是個沒有效率的做法,未來擴充 method 的時候就需要一直手動的添加 event...

import { defineConfig } from 'cypress';
import { ExcelReader } from './cypress/plugin';

export default defineConfig({
  e2e: {
    // ...
    setupNodeEvents(on, config) {
      const excelReader = new ExcelReader(config);

      on('task', {
        // 這個 plugin class 有幾個 methods 就必須手動寫幾次
        readXlsx: (filepath: string) => excelReader.readXlsx.bind(excelReader);
      });

      return config;
    },
  },
});

雖然全部寫成 function 再 export 是個簡單的作法,但我想要為我的 plugin 都引入 config 這個參數,ES6 的 class 語法糖可以很輕鬆地繼承父類別的方法和參數:

// cypress/plugin/Plugin.ts
export default abstract class Plugin {
  constructor(protected config: Cypress.PluginConfigOptions) {}
}

// cypress/plugin/ExcelReader.ts
import xlsx from "node-xlsx";
import { join } from "path";
import Plugin from "./Plugin";

export default class ExcelReader extends Plugin {
  readXlsx(filename: string) {
    return xlsx.parse(join(this.config.downloadsFolder, filename));
  }

  // ...
}

從實例中提取 method

我們會在 cypress.config.ts 中新建 ExcelReader 實例,但要如何從 instance 提取呢?

從實例中取得 property 名稱與 value,如果 value 是 function 的話,將它存成 object 的 key 與 value,當然這時候的 value 是沒有辦法存取實例中的 config 的,因此要記得把 value 綁定實例,最後再返回這個 object

// cypress/support/utils
import type Plugins from "../../plugins/Plugin";

export function getMethodFromInstance(instance: Plugins) {
  return Object.getOwnPropertyNames(instance).reduce((task, key) => {
    if (typeof Object.getPrototypeOf(key) === "function") {
      task[`${instance.constructor.name}_${key}`] = Object.getPrototypeOf(key)
        .bind(instance);
    }
    return task;
  }, {} as Record<string, (...args: any) => any>);
}

可以注意到上面在 assign task 的 key 時,並不是直接存實例的 key,而是在前面又加上了類別的名稱,這是因爲如果 plugin 之間有重複名稱的 method 出現,後 import 的 method 就會將前面的 method 給覆蓋,所以算是一個權宜之計。

將 method 加入 event

有了 getMethodFromInstance 後,就可以利用 Spread Operator 將提取出來的 key 和 value 放入 task event 中。

import { defineConfig } from "cypress";
import { ExcelReader } from "./cypress/plugin";
import { getMethodFromInstance } from "./cypress/support/utils";

export default defineConfig({
  e2e: {
    //...
    setupNodeEvents(on, config) {
      const excelReader = new ExcelReader(config);

      on("task", {
        ...getMethodFromInstance(excelReader),
      });

      return config;
    },
  },
});

載入 type

到以上的步驟是可以直接調用 cy.task 的,但是爲了要讓 VSCode 的 IntelliSense 發揮作用就必須定義 types:

// global.d.ts

declare namespace Cypress {
  interface Chainable<Subject = any> {
    task(
      event: 'ExcelReader_readXlsx',
      arg?: string
      options?: Partial<Loggable & Timeoutable>
    ): Chainable<string[]>;
  }
}

不過顯然會遇到和設定 task event 時一樣的問題,有幾個 method 就需要手動填入,而且因爲有設定 key 前面加上 plugin 的類別名稱,如果之後換名稱一個一個宣告 types 會造成維護上的困難。

下面雖然光是提取出有用的 type 宣告了一堆 type,但了解之後其實沒有那麽複雜,後續也只要專注在維護新增 plugin 或是更改 prefix 的問題,不過下面的 type 還有很多的改善空間,但總的來説已經能滿足我現階段的需求。

// global.d.ts

// 從 type 中提取 value 是 function 的 name
type FunctionPropertyNames<T> = Pick<
  T,
  {
    [K in keyof T]: T[K] extends Function ? K : never;
  }[keyof T]
>;
// 將提取出來的 function name key 變成 union
type KeysOfUnion<T> = T extends T ? keyof FunctionPropertyNames<T> : never;
// 藉由提供的 type 與 key 去提出該 function 的 parameter 並 union
type ParametersUnion<T, K> = T extends T ? Parameters<T[K]> : never;
// 藉由提供的 type 與 key 去提出該 function 的 return type 並 union
type ReturnTypeUnion<T, K> = T extends T ? ReturnType<T[K]> : never;

type ValueOf<T> = T[keyof T];
// 類似 array 的 split,給定一個原始 string 與要 split 的 string,最後會返回一個 string array
type Split<S extends string, D extends string> = string extends S ? string[]
  : S extends "" ? []
  : S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>]
  : [S];
// 將原本的 key 都加上對應的 prefix
type AddPrefix<T extends Plugins> = T extends ExcelReader
  ? `ExcelReader_${KeysOfUnion<T>}`
  : T extends FolderReader ? `FolderReader_${KeysOfUnion<T>}`
  : never;
// 爲了正確的得到 type 的 value,需要將 prefix 給移除
type RemovePrefix<K> = Split<K, `${PluginsName}_`>[1];

// 取得所有在 plugins/index.ts 的 plugin instance union
type Plugins = InstanceType<ValueOf<typeof import("./plugins")>>;
type PluginsName = KeysOfUnion<typeof import("./plugins")>;
// 單獨取得 instance 將用於 AddPrefix 中
type ExcelReader = import("./plugins").ExcelReader;
type FolderReader = import("./plugins").FolderReader;

/**
 * 藉由 generic 讓 event key extends 增加 prefix 的 key name
 * 有了 key name 就可以取得相應的 parameter 與 return type
 */
declare namespace Cypress {
  interface Chainable<Subject = any> {
    task<K extends AddPrefix<Plugins>>(
      event: K,
      // 只拿第一個 parameter,因爲 task 只能接受一個
      arg?: ParametersUnion<Plugins, RemovePrefix<K>>[0],
      options?: Partial<Loggable & Timeoutable>,
    ): Chainable<ReturnTypeUnion<Plugins, RemovePrefix<K>>>;
  }
}