GAS を Clasp + esbuild + TypeScript + Jest + Git 環境で管理・開発する

# はじめに

こんにちは。debiru です。

スプレッドシート上で key-value 形式のデータを管理して、それを JSON として吐き出し、JSON を利用して各種アプリケーションにデータを Import するというアプローチを採用してシステム開発を行っています。

デザインシステムのデザイントークンをこれで管理して Figma の Local Variables にデータを Import するための Figma プラグインを作ったという記事を前回書きましたが、今回はスプレッドシート + GAS 側の開発手法についてのお話です。

https://qiita.com/debiru/items/11f36ad5db5be8a120bc

# TL;DR

- スプレッドシートを作成して `SPREADSHEET_ID` と `GAS_ID` を控える
- `git clone git@github.com:debiru/clasp.git` を実行する
- 中にある `.clasp.json` の ID を控えたものに差し替える
- https://script.google.com/home/usersettings をオンにする
- `npm install` して `npx clasp login` して `npm run push` する
- JSON Web API を使いたければ (6.) の手順を実行する

# 開発手順

- (1.) Git (GitHub) リポジトリを用意する
- (2.) スプレッドシートを用意する
- (3.) clasp コマンドを使って GAS を clone する
- (4.) TypeScript をトランスパイルできるようにする
- (5.) `project.ts` を実装する
- (6.) `project.js` を push して初期デプロイする
- (7.) Jest を導入し `package.json` を整備する
- (8.) Jest のテストを実装する

## 1. Git (GitHub) リポジトリを用意する

まず、Git リポジトリを用意します。

- https://github.com/debiru/clasp
- `git@github.com:debiru/clasp.git`

このリポジトリを手元で clone して、作業ディレクトリとして使っていきます。

```
$ cd /path/to
$ git clone git@github.com:debiru/clasp.git
$ cd clasp
```

後述する作業に合わせて、適宜 commit していきます。

### `.gitignore` を用意しておく

```
Thumbs.db
.DS_Store

node_modules
package-lock.json

coverage
```

- `package-lock.json`
    - npm install 時にパッケージのバージョンを固定する必要がないので、最新版を導入できるように `package-lock.json` は管理しないでおきます。
- `coverage`
    - jest のテスト実行時に生成されるディレクトリです。バージョン管理する必要がないので無視しておきます。

## 2. スプレッドシートを用意する

自身の Google アカウント上でスプレッドシートを作成します。

ここでは Google Workspace（旧 G Suite, 旧 Google Apps）のアカウントではなく、無料の個人アカウントで作業している前提で説明します（Google Workspace アカウントの場合、Web API の URL の形式が少し変わります）。

- スプレッドシート
    - https://docs.google.com/spreadsheets/d/1Q6NgOGt9X2PaWMWYsAJf2S6IwisEKODzGNDnELxRa80/edit?usp=sharing
    - `/d/` と `/edit` の間の文字列が `__SPREADSHEET_ID__` です
- GAS
    - https://script.google.com/u/0/home/projects/1-_Han3yPynNQM8XWEuA7VQEhoVTWRBduXAq4EF4_1UWPDRFUpei5Iafo/edit
    - `/projects/` と `/edit` の間の文字列が `__GAS_ID__` です

GAS はスプレッドシート上の「拡張機能」から「Apps Script」を選択して表示されるページです。

### スプレッドシートの初期設定

スプレッドシートを初期設定しておきます。以下は今回のサンプルの例です。

- スプレッドシート名を設定：Clasp Example
- シート名を設定：scripts
- セル内容：1行目にキー名、2行目以降にレコード（値）を記述しておく

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178936/495b275a-7aab-1efb-cdab-e7735210d2ee.png)

### GAS の初期設定

GAS を初期設定しておきます。

- GAS 名を設定：Clasp Example
- スクリプトファイル名を設定：project.gs
- スクリプトの初期化：内容を空にして保存しておく

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178936/977bfe36-b6be-b9df-0508-b7f14c8669e7.png)

## 3. clasp コマンドを使って GAS を clone する

- Google Apps Script API を使えるようにします
    - https://script.google.com/home/usersettings

git clone したディレクトリに移動して作業します。

```
$ npm install -D @google/clasp

$ ls
README.md  node_modules  package-lock.json  package.json

$ npx clasp login
# ブラウザが起動してログイン処理が実行されます

$ npx clasp login --status
You are logged in as YOUR_NAME@gmail.com

$ mkdir dst
$ clasp clone __GAS_ID__ --rootDir dst
# dst ディレクトリ下に .clasp.json と appsscript.json が生成されます

# .clasp.json はプロジェクトルートに設置します
$ mv dst/.clasp.json .
```

### `.clasp.json` に `__SPREADSHEET_ID__` を追記する

- `"spreadsheetId"` キーを追加します
- ついでに JSON を改行して見やすくしておきます

```
{
  "spreadsheetId": "1Q6NgOGt9X2PaWMWYsAJf2S6IwisEKODzGNDnELxRa80",
  "scriptId": "1-_Han3yPynNQM8XWEuA7VQEhoVTWRBduXAq4EF4_1UWPDRFUpei5Iafo",
  "rootDir": "dst"
}
```

### `dst/appsscript.json` に `webapp` キーを追記する

- clasp で webapp デプロイができるように `"webapp"` キーを追記します
- `"access"` には Web API を誰でも参照できるように `"ANYONE_ANONYMOUS"` を指定しておきます
    - https://developers.google.com/apps-script/manifest/web-app-api-executable?hl=ja

```
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "webapp": {
    "access": "ANYONE_ANONYMOUS",
    "executeAs": "USER_DEPLOYING"
  }
}
```

### clasp から push を試してみる

- `dst` 下に `project.js` を設置して `clasp push` を実行するとスクリプトの保存ができます
- `push` の `−f` オプションは、マニフェストファイルである `appsscript.json` がローカルとリモートで異なっているときに `Manifest file has been updated. Do you want to push and overwrite?` と訊かれるのをスキップするために指定しています

```
$ touch dst/project.js
# dst/project.js を編集します

$ cat dst/project.js 
function hello() {
  return 'hello';
}

$ npx clasp push -f
└─ dst/appsscript.json
└─ dst/project.js
Pushed 2 files.
```

ブラウザから GAS ページをみると、スクリプトが更新されていることが確認できます。

## 4. TypeScript をトランスパイルできるようにする

実は clasp 自体が TypeScript に対応していて、前述の `dst/project.js` を `dst/project.ts` として記述するだけで、`clasp push` 時に自動的にサーバーサイドで JS にトランスパイルしてくれます。しかし、clasp の TypeScript 自動トランスパイルは単一ファイルのみに対応していて、Import 文が使えません。

Import 文を使ってファイル分割して TypeScript で開発するにはローカルで手動トランスパイルする必要があります。ローカルでトランスパイルできるようにするために `esbuild` を導入しましょう。

### `esbuild` を導入する

```
$ npm install -D esbuild ts-node @types/node

$ mkdir src
$ touch src/project.ts
# src/project.ts を編集します

$ cat src/project.ts
export function hello() {
  return 'hello';
}

$ mkdir bin
$ touch bin/build.ts
# bin/build.ts を編集します
```

```
$ cat bin/build.ts
#!/usr/bin/env npx ts-node

import { build } from 'esbuild';

build({
  entryPoints: ['src/project.ts'],
  outfile: 'dst/project.js',
  bundle: true,
  banner: {
    js: [
      '// This file was generated by clasp. DO NOT EDIT by hand.',
      '// Top-level `var` declaration: https://esbuild.github.io/faq/#top-level-var',
      '',
    ].join('\n'),
  },
  footer: {
    js: [
      '',
      '// Copyright @debiru_R from https://github.com/debiru/clasp',
    ].join('\n'),
  },
}).catch(() => process.exit(1));
```

- `banner` プロパティでファイルの先頭に、`footer` プロパティでファイルの末尾に任意の文字列を追加できます

```
$ chmod +x bin/build.ts

# src/project.ts から dst/project.js を生成します
$ bin/build.ts

$ cat dst/project.js
// This file was generated by clasp. DO NOT EDIT by hand.
// Top-level `var` declaration: https://esbuild.github.io/faq/#top-level-var

(() => {
  // src/project.ts
  function hello() {
    return "hello";
  }
})();

// Copyright @debiru_R from https://github.com/debiru/clasp
```

- `esbuild` でトランスパイルしたコードは、上記のように即時実行関数式（IIFE）の中に閉じ込められます

### `tsconfig.json` を設置する

```
$ touch tsconfig.json
# tsconfig.json を編集します

$ cat tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "typeRoots": ["node_modules/@types", "src/types"],
    "esModuleInterop": true,
    "resolveJsonModule": true
  }
}
```

## 5. `project.ts` を実装する

- スプレッドシートに記入した内容を JSON で返す Web API を用意します

```
$ npm install -D @types/google-apps-script

$ tree src 
src
├── Entrypoint
│   └── index.ts
├── Sheet
│   └── index.ts
├── Util
│   └── index.ts
├── global
│   ├── export.ts
│   └── index.ts
├── project.ts
└── types
    └── index.d.ts
```

### `Entrypoint/index.ts`

```
import { JSON_FIELDS, SHEETS } from "../global";
import { Sheet } from "../Sheet";
import { Util } from "../Util";

export const Entrypoint = {
  doGet(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent) {
    return Entrypoint.doMain(e);
  },

  doPost(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent) {
    return Entrypoint.doMain(e);
  },

  /**
   * the "e" argument represents an event parameter that can contain information about any URL parameters.
   * refs. https://developers.google.com/apps-script/guides/web
   */
  doMain(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent) {
    return Entrypoint.makeContent(
      Entrypoint.makeResponse(e, () => Entrypoint.makeFormattedObject(e.parameter.type))
    );
  },

  makeFormattedObject(type: string) {
    const keyValueData = Entrypoint.makeObject();
    // If you want another JSON format, convert the data structure here.
    // (ex.) if (type === 'figma') return Formatter.figmaFormatter(keyValueData);
    return keyValueData;
  },

  makeObject() {
    const jsonObj = Util.objMap(JSON_FIELDS, (value: string) => Sheet.getRecords(value));
    return jsonObj;
  },

  makeResponse(e: GoogleAppsScript.Events.AppsScriptHttpRequestEvent, callback: CallableFunction) {
    const data = callback();
    const json = Util.toJSON(data);

    const useJsonp = !Util.empty(e.parameter.callback);
    const response = {
      mime: useJsonp ? ContentService.MimeType.JAVASCRIPT : ContentService.MimeType.JSON,
      content: (useJsonp ? Util.sprintf('%s(%s);', e.parameter.callback, json) : json) + '\n',
    };

    return response;
  },

  makeContent(response: { mime: GoogleAppsScript.Content.MimeType, content: string }) {
    return ContentService.createTextOutput(response.content).setMimeType(response.mime);
  },
};
```

### `Sheet/index.ts`

```
import { SID } from '../global';

export const Sheet = {
  get(sheetName: string) {
    return SpreadsheetApp.openById(SID).getSheetByName(sheetName);
  },

  getCells(sheetName: string) {
    const sheet = Sheet.get(sheetName);
    if (sheet == null) return null;
    return sheet.getDataRange().getValues() as Array<Array<CellValue>>;
  },

  getRecords(sheetName: string) {
    const cells = Sheet.getCells(sheetName);
    if (cells == null) return null;

    // values[row][col] を records[row][key] のオブジェクト配列に変換
    const keys = cells.shift();
    const records = cells.map((row: Array<CellValue>) => {
      const obj = {};
      row.forEach((cell: CellValue, i: number) => {
        obj[String(keys[i])] = cell;
      });
      return obj;
    });

    // 値が全て空の列があれば key から除外する
    keys.forEach((key: string) => {
      const set = new Set();
      records.forEach((record: DataRecord) => set.add(record[key]));
      if (set.size === 1 && set.has('')) {
        records.map((record: DataRecord) => {
          delete(record[key]);
          return record;
        });
      }
    });

    return records as DataRecords;
  },

  getHeaders(sheetName: string) {
    const cells = Sheet.getCells(sheetName);
    if (cells == null) return null;
    return cells.shift().map((v: CellValue) => String(v));
  },
};
```

### `Util/index.ts`

```
export const Util = {
  /**
   * 値を JSON 文字列に変換する。
   */
  toJSON(arg: unknown) {
    return JSON.stringify(arg, null, 2);
  },

  /**
   * null または undefined または空文字列であれば true を返す。
   */
  empty(arg: unknown) {
    return arg == null || arg === '';
  },

  /**
   * %s プレースホルダーのみに対応した sprintf 関数。
   */
  sprintf(format: string, ...args: any) {
    let p = 0;
    return format.replace(/%./g, function(m) {
      if (m === '%%') return '%';
      if (m === '%s') return args[p++];
      return m;
    });
  },

  /**
   * Object の key-value を新しい値で更新する。
   * obj の他に valueCallback と keyCallback を引数に取る。
   * callback 関数は引数に元の value と key をこの順に取る。
   */
  objMap(obj: PlainObject<any>, valueCallback?: CallableFunction, keyCallback?: CallableFunction) {
    if (valueCallback == null) valueCallback = (value: any) => value;
    if (keyCallback == null) keyCallback = (value: any, key: ObjectKey) => key;
    const ret: typeof obj = {};
    Object.entries(obj).forEach(([key, value]: [ObjectKey, any]) => ret[keyCallback(value, key)] = valueCallback(value, key));
    return ret;
  },

  /**
   * keyArray と valueArray から Object を作成する。
   * valueArray を指定しない場合、keyArray が使われる。
   */
  arrayCombine(keyArray: Array<any>, valueArray?: Array<any>) {
    if (valueArray == null) {
      valueArray = keyArray;
      keyArray = Object.keys(keyArray);
    }
    const ret: PlainObject<any> = {};
    for (let i = 0; i < keyArray.length; ++i) ret[keyArray[i]] = valueArray[i];
    return ret;
  },
};
```

### `global/export.ts`

```
export const Export: Record<string, Array<string>> = {
  Entrypoint: ['doGet', 'doPost'],
};
```

### `global/index.ts`

```
import clasp from '../../.clasp.json';
export const SID = clasp.spreadsheetId;

export const SHEETS = {
  SCRIPTS: 'scripts',
  UNKNOWN: 'unknown',
};

export const COL = {
  SCRIPT_NAME: 'scriptName',
  COMMAND: 'command',
  UNKNOWN: 'unknown',
};

export const JSON_FIELDS = {
  [SHEETS.SCRIPTS]: SHEETS.SCRIPTS,
};
```

### `project.ts`

```
export * from './global';
import { Entrypoint } from './Entrypoint';
import { Sheet } from './Sheet';
import { Util } from './Util';

// Export Functions to Global
const variables = { Entrypoint, Sheet, Util };
import { Export } from './global/export';
Object.entries(Export).forEach(([namespace, functionNameList]: [string, Array<string>]) => {
  functionNameList.forEach((functionName: string) => {
    globalThis[functionName] = variables[namespace][functionName];
  });
});
```

### `types/index.d.ts`

```
type PlainObject<T> = Record<string, T>;
type ObjectKey = string | number;
type CellValue = string | number | boolean;
type DataRecordValue = CellValue;
type DataRecord = PlainObject<DataRecordValue>;
type DataRecords = Array<DataRecord>;
```

### `bin/build.ts`

```
#!/usr/bin/env npx ts-node

import { build } from 'esbuild';
import { Export } from '../src/global/export';

const globalFunctions: Array<string> = [];
Object.entries(Export).forEach(([namespace, functionNameList]: [string, Array<string>]) => {
  functionNameList.forEach((functionName: string) => {
    globalFunctions.push(`function ${functionName}() {}`);
  });
});

build({
  entryPoints: ['src/project.ts'],
  outfile: 'dst/project.js',
  bundle: true,
  banner: {
    js: [
      '// This file was generated by clasp. DO NOT EDIT by hand.',
      '// Top-level `var` declaration: https://esbuild.github.io/faq/#top-level-var',
      '',
    ].join('\n'),
  },
  footer: {
    js: [
      '',
      ...globalFunctions,
      '',
      '// Copyright @debiru_R from https://github.com/debiru/clasp',
    ].join('\n'),
  },
}).catch(() => process.exit(1));
```

## 6. `project.js` を push して初期デプロイする

```
$ bin/build.ts
$ npx clasp push -f
```

- ブラウザ上から GAS ページを開いて、デプロイ（新しいデプロイ）を実行します

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178936/37ae9515-3f76-8729-983b-bf2f2640acf5.png)

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178936/5a92a7a9-d32c-69c2-8209-6ef21f02bd38.png)

- 「新しい説明文」には `Initial deployment` とでも記述しておきます
- デプロイを実行します

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178936/cfcd9612-913f-b142-0a4b-036fbcfcda95.png)

- スクリプトコード中で `SpreadsheetApp.openById(SID)` などの API を使用している箇所に応じて、権限が求められます
- 「アクセスを承認」を選択して、自身のアカウントでこのアプリケーションに対して権限を付与します
- Web API のエンドポイントが生成されます
    - https://script.google.com/macros/s/AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh/exec
- これ以降は clasp からコマンドラインでデプロイができるようになります


### 更新デプロイをする方法

新規デプロイを新たに行ってしまうと Web API の URL が変わってしまいます。これを防ぐためには、ブラウザ上で操作する場合は「デプロイの管理」から編集を行い「新バージョン」でデプロイを実行します。clasp から操作する場合は、`deploymentId` オプションを付与してデプロイを実行します。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178936/a6880c4d-7026-e167-05ed-a35231bf2405.png)

## 7. Jest を導入し `package.json` を整備する

```
$ npm install -D jest ts-jest @types/jest
```

- `package.json` を編集します
    - `"name"`, `"version"`, `"scripts"` を追記します
    - `"name"` の値には大文字が使えないため kebab-case で記述します
    - `"scripts"` 内に定義したキーは `npm run deployments` のように `npm run` コマンドとして実行できるようになります

```
{
  "name": "clasp-example",
  "version": "1.0.0",
  "devDependencies": {
    "@google/clasp": "^2.4.2",
    "@types/google-apps-script": "^1.0.83",
    "@types/jest": "^29.5.12",
    "@types/node": "^22.2.0",
    "esbuild": "^0.23.0",
    "jest": "^29.7.0",
    "ts-jest": "^29.2.4",
    "ts-node": "^10.9.2"
  },
  "scripts": {
    "deployments": "clasp deployments",
    "deployment": "bin/getDeploymentId.sh",
    "show": "bin/show.sh",
    "test": "jest --passWithNoTests",
    "prebuild": "npm run test",
    "build": "bin/build.ts",
    "prepush": "npm run build",
    "push": "clasp push -f",
    "preforce-push": "bin/build.ts",
    "force-push": "clasp push -f",
    "deploy": "bin/deploy.sh"
  }
}
```

- `bin` 以下にシェルスクリプトを作成します

```
touch bin/deploy.sh bin/getDeploymentId.sh bin/show.sh
chmod +x bin/*.sh
```

### `bin/deploy.sh`

```
#!/bin/bash

cd $(dirname $0)

ask_yes_no() {
    echo -n "$* [Y/n]: "
    read ANS
    case $ANS in
        [Yy]*)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}

DEPLOYMENT_ID="$(./getDeploymentId.sh)"
DATETIME="$(date "+%Y-%m-%d %H:%M:%S")"
NAME="$(git config user.name)"

if [ -z "$DEPLOYMENT_ID" ]; then
    echo "初回は手動で webapp デプロイを実行する必要があります"
    exit 1
fi

if ask_yes_no "最後に push されたコードでデプロイを実行しますか？"; then
    npx clasp deploy --deploymentId "${DEPLOYMENT_ID}" --description "by ${NAME} at ${DATETIME}"
else
    echo "Aborted."
fi
```

### `bin/getDeploymentId.sh`

```
#!/bin/bash

cd $(dirname $0)

DEPLOYMENTS="$(npx clasp deployments)"

LAST_DEPLOYMENT_ID=$(echo "$DEPLOYMENTS" | grep '^-' | awk '{print $3, $2}' | grep -v '^@HEAD' | sort -V | tail -1 | awk '{print $2}')

echo "$LAST_DEPLOYMENT_ID"
```

### `bin/show.sh`

```
#!/bin/bash

cd $(dirname $0)

CLASP_JSON="$(cat ../.clasp.json)"
SPREADSHEET_ID="$(echo $CLASP_JSON | xargs -0 -i node -pe '({}).spreadsheetId')"
DEPLOYMENT_ID="$(./getDeploymentId.sh)"

webapp_url() {
    URL="https://script.google.com/macros/s/${DEPLOYMENT_ID}/exec"
    if [ $# -ge 1 ]; then
        URL="${URL}?type=${1}"
    fi
    echo "$URL"
}

echo "- Spreadsheet URL:"
echo "https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/edit"
echo ""
echo "- Key-Value JSON:"
echo "$(webapp_url)"
echo ""
```

### `jest.config.ts` を生成する

```
$ npx jest --init  

The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … no
✔ Would you like to use Typescript for the configuration file? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls, instances, contexts and results before every test? … yes

📝  Configuration file created at /path/to/clasp/jest.config.ts
```

- 更に、以下の変更を加える
    - `preset` の値を `'ts-jest'` にする
    - `transformIgnorePatterns` の値をアンコメントする

```
diff --git a/jest.config.ts b/jest.config.ts
index 04d4c9c..9b4c34a 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -102,7 +102,7 @@ const config: Config = {
   // notifyMode: "failure-change",
 
   // A preset that is used as a base for Jest's configuration
-  // preset: undefined,
+  preset: 'ts-jest',
 
   // Run tests from one or more projects
   // projects: undefined,
@@ -178,10 +178,10 @@ const config: Config = {
   // transform: undefined,
 
   // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
-  // transformIgnorePatterns: [
-  //   "/node_modules/",
-  //   "\\.pnp\\.[^\\/]+$"
-  // ],
+  transformIgnorePatterns: [
+    "/node_modules/",
+    "\\.pnp\\.[^\\/]+$"
+  ],
 
   // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
   // unmockedModulePathPatterns: undefined,
```

## 8. Jest のテストを実装する

```
$ tree src
src
├── Entrypoint
│   ├── index.spec.ts
│   └── index.ts
├── Sheet
│   ├── index.spec.ts
│   └── index.ts
├── Util
│   ├── index.spec.ts
│   └── index.ts
├── global
│   ├── export.ts
│   ├── index.spec.ts
│   ├── index.ts
│   ├── mock.spec.ts
│   └── mock.ts
├── project.ts
└── types
    └── index.d.ts
```

### `Entrypoint/index.spec.ts`

```
import '../global/mock';
import { Mock } from '../global/mock';

import { Entrypoint } from '.';
import { Util } from '../Util';

describe('Entrypoint.spec', () => {
  describe('Entrypoint Methods', () => {
    it('doGet() returns jestResult', () => {
      const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent) as any;
      expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
      expect(typeof result.jestResult.content).toBe('string');
    });

    it('doPost() returns jestResult', () => {
      const result = Entrypoint.doPost(globalThis.AppsScriptHttpRequestEvent) as any;
      expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
      expect(typeof result.jestResult.content).toBe('string');
    });
  });

  describe('doMain', () => {
    it('doMain returns Key-Value JSON', () => {
      const result = Entrypoint.doMain(globalThis.AppsScriptHttpRequestEvent) as any;
      expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
      expect(JSON.parse(result.jestResult.content)).toEqual({
        scripts: [
          {
            scriptName: '1scriptName/scripts',
            command: '1command/scripts',
          },
        ],
      });
    });
  });

  describe('makeResponse', () => {
    it('makeResponse supports JSONP', () => {
      const callbackName = 'jsonpCallbackName';
      const content = { a: 'hello', b: 'world' };
      globalThis.AppsScriptHttpRequestEvent.setParam('callback', callbackName);
      const result = Entrypoint.makeResponse(globalThis.AppsScriptHttpRequestEvent, () => content);
      expect(result.mime).toBe(globalThis.ContentService.MimeType.JAVASCRIPT);
      expect(result.content).toBe(`${callbackName}(${Util.toJSON(content)});\n`);
    });
  });
});
```

### `Sheet/index.spec.ts`

```
import '../global/mock';
import { extJest, Mock } from '../global/mock';

import { Sheet } from '.';
import { SHEETS } from '../global';

describe('Sheet.spec', () => {
  describe('Success of Sheet methods', () => {
    it('getCells() has exist sheetName', () => {
      const result = Sheet.getCells(SHEETS.SCRIPTS);
      expect(result).not.toBeNull();
    });

    it('getRecords() has exist sheetName', () => {
      const result = Sheet.getRecords(SHEETS.SCRIPTS);
      expect(Array.isArray(result)).toBeTruthy();
      expect(result.length).toBeGreaterThanOrEqual(1);
    });

    it('getHeaders() has exist sheetName', () => {
      const result = Sheet.getHeaders(SHEETS.SCRIPTS);
      expect(Array.isArray(result)).toBeTruthy();
      expect(result.length).toBeGreaterThanOrEqual(1);
    });
  });

  describe('Special cases', () => {
    it('getRecords() removes empty columns', () => {
      const originalGetValuesBySheet = Mock.getValuesBySheet;
      extJest.makeMock(Mock, 'getValuesBySheet').mockImplementation((sheetName: string) => {
        const values = originalGetValuesBySheet(sheetName);
        return values.map(row => row.map(col => col.replace(/\d+command\/scripts/, '')));
      });
      const result = Sheet.getRecords(SHEETS.SCRIPTS);
      expect(result).toEqual([{ scriptName: '1scriptName/scripts' }]);
    });
  });

  describe('Exception of Sheet methods', () => {
    it('getCells() does not have exist sheetName', () => {
      const result = Sheet.getCells(SHEETS.UNKNOWN);
      expect(result).toBeNull();
    });

    it('getRecords() does not have exist sheetName', () => {
      const result = Sheet.getRecords(SHEETS.UNKNOWN);
      expect(result).toBeNull();
    });

    it('getHeaders() does not have exist sheetName', () => {
      const result = Sheet.getHeaders(SHEETS.UNKNOWN);
      expect(result).toBeNull();
    });
  });
});
```

### `Util/index.spec.ts`

```
import '../global/mock';

import { Util } from '.';

describe('Util.spec', () => {
  const valuesTable = [null, undefined, '', false, true, 0, '0', () => 0, [], {}];

  describe('toJSON', () => {
    it('success', () => {
      expect(Util.toJSON({ a: 1, b: 'b' })).toBe('{\n  "a": 1,\n  "b": "b"\n}');
    });
  });

  describe('empty', () => {
    it('success', () => {
      const result = valuesTable.map(v => Util.empty(v));
      const expectResult = [true, true, true, false, false, false, false, false, false, false];
      expect(result).toEqual(expectResult);
    });
  });

  describe('sprintf', () => {
    it('assign arguments', () => {
      expect(Util.sprintf('%s,%s', 1, 2)).toBe('1,2');
    });

    it('escape character', () => {
      expect(Util.sprintf('%%s', 1)).toBe('%s');
    });

    it('undefined placeholder', () => {
      expect(Util.sprintf('%d,%s', 1, 2)).toBe('%d,1');
    });
  });

  describe('objMap', () => {
    it('valueCallback', () => {
      const obj = {
        one: 1,
        two: 2,
        three: 3,
      };
      expect(Util.objMap(obj, (v: number) => v * 2)).toEqual({ one: 2, two: 4, three: 6 });
    });

    it('keyCallback', () => {
      const obj = {
        one: 1,
        two: 2,
        three: 3,
      };
      expect(Util.objMap(obj, null, (v: number, k: string) => `${k}_${v}`)).toEqual({ one_1: 1, two_2: 2, three_3: 3 });
    });
  });

  describe('arrayCombine', () => {
    it('1 argument', () => {
      expect(Util.arrayCombine(['a', 'b', 'c'])).toEqual({ 0: 'a', 1: 'b', 2: 'c' });
    });

    it('2 arguments', () => {
      expect(Util.arrayCombine(['a', 'b', 'c'], ['x', 'y', 'z'])).toEqual({ a: 'x', b: 'y', c: 'z' });
    });
  });
});
```

### `global/index.spec.ts`

```
import './mock';
import { SID } from '.';

describe('Global Definitions', () => {
  it('SID is not empty', () => {
    expect(SID).not.toBeFalsy();
  });
});
```

### `global/mock.spec.ts`

```
import './mock';
import { extJest, Mock } from './mock';

import { SHEETS } from '.';
import { Entrypoint } from '../Entrypoint';
import { Sheet } from '../Sheet';

describe('mock.spec', () => {
  describe('Mocks are unmocked for each test case', () => {
    it('Mock doMain', () => {
      const doMain = extJest.makeMock(Entrypoint, 'doMain');
      const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent);
      expect(result).toBeUndefined();
    });

    it('Subsequent tests', () => {
      const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent);
      expect(result).not.toBeUndefined();
    });
  });

  describe('Query parameters are reset for each test case', () => {
    it('Set query parameter', () => {
      globalThis.AppsScriptHttpRequestEvent.setParam('callback', 'jsonp');
      const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent) as any;
      expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JAVASCRIPT);
    });

    it('Subsequent tests', () => {
      const result = Entrypoint.doGet(globalThis.AppsScriptHttpRequestEvent) as any;
      expect(result.jestResult.mime).toBe(globalThis.ContentService.MimeType.JSON);
    });
  });

  describe('ActiveSheet is reset for each test case', () => {
    it('Set activeSheetName', () => {
      (globalThis.SpreadsheetApp as any).setActiveSheetName(SHEETS.UNKNOWN);
      const result = (globalThis.SpreadsheetApp as any).getActiveSheetName();
      expect(result).toBe(SHEETS.UNKNOWN);
    });

    it('Subsequent tests', () => {
      const result = (globalThis.SpreadsheetApp as any).getActiveSheetName();
      expect(result).toBe(SHEETS.SCRIPTS);
    });
  });

  describe('Check to see if Mock is broken', () => {
    it('Sheet.getActiveSheetInfo.cells', () => {
      for (let i = 0; i <= 10; ++i) {
        (globalThis.SpreadsheetApp as any).setRecordsNumber(i);
        const records = Sheet.getRecords(SHEETS.SCRIPTS);
        expect(records.length).toBe(i);
      }
    });
  });

  describe('Exception of methods', () => {
    it('SpreadsheetApp.getSheetByName', () => {
      expect((globalThis.SpreadsheetApp as any).openById(null).getSheetByName(SHEETS.UNKNOWN)).toBeNull();
    });

    it('Mock.getValuesBySheet', () => {
      expect(Mock.getValuesBySheet(SHEETS.UNKNOWN)).toEqual([[]]);
    });
  });
});
```

### `global/mock.ts`

```
import { COL, SHEETS } from ".";

export const extJest = {
  mocks: [],
  makeMock: (parent: Object, prop: string): jest.Mock => {
    const bak = Object.assign({}, parent)[prop];
    extJest.mocks.push({ parent, prop, bak });
    parent[prop] = jest.fn();
    return parent[prop];
  },
};

afterEach(() => {
  extJest.mocks.reverse().forEach((mock) => mock.parent[mock.prop] = mock.bak);
  extJest.mocks = [];
  globalThis.AppsScriptHttpRequestEvent.reset();
  (globalThis.SpreadsheetApp as any).reset();
});

globalThis.AppsScriptHttpRequestEvent = {
  parameter: {},
  setParam: (key: string, value: string) => {
    globalThis.AppsScriptHttpRequestEvent.parameter[key] = value;
  },
  reset: () => globalThis.AppsScriptHttpRequestEvent.parameter = {},
} as any;

globalThis.ContentService = {
  jestResult: {},
  MimeType: {
    JAVASCRIPT: 'JAVASCRIPT',
    JSON: 'JSON',
  },
  createTextOutput: (content: string) => {
    ((globalThis.ContentService) as any).jestResult.content = content;
    return globalThis.ContentService;
  },
  setMimeType: (mime: string) => {
    ((globalThis.ContentService) as any).jestResult.mime = mime;
    return globalThis.ContentService;
  },
} as any;

globalThis.SpreadsheetApp = {
  openById: (spreadsheetId: string) => ({
    getSheetByName: (sheetName: string) => {
      (globalThis.SpreadsheetApp as any).setActiveSheetName(sheetName);
      if (Mock.getBaseRecords(sheetName).length === 0) return null;
      return (globalThis.SpreadsheetApp as any).getSheetByName;
    },
  }),
  getSheetByName: {
    getDataRange: () => (globalThis.SpreadsheetApp as any).getDataRange,
  },
  getDataRange: {
    getValues: () => Mock.getValuesBySheet((globalThis.SpreadsheetApp as any).getActiveSheetName()),
  },
  activeSheetName: null,
  getActiveSheetName: () => (globalThis.SpreadsheetApp as any).activeSheetName ?? SHEETS.SCRIPTS,
  setActiveSheetName: (sheetName: string) => (globalThis.SpreadsheetApp as any).activeSheetName = sheetName,
  recordsNumber: null,
  getRecordsNumber: () => (globalThis.SpreadsheetApp as any).recordsNumber ?? 1,
  setRecordsNumber: (number: number) => (globalThis.SpreadsheetApp as any).recordsNumber = number,
  reset: () => {
    (globalThis.SpreadsheetApp as any).activeSheetName = null;
    (globalThis.SpreadsheetApp as any).recordsNumber = null;
  },
} as any;

export const Mock = {
  getBaseRecords: (sheetName?: string): Array<string> => {
    const map: any = {
      [SHEETS.SCRIPTS]: [COL.SCRIPT_NAME, COL.COMMAND],
    };

    return sheetName == null ? map : map[sheetName] ?? [];
  },
  getValuesBySheet: (sheetName: string): Array<Array<string>> => {
    const map = Mock.getBaseRecords();
    if (map[sheetName] == null) return [[]];

    const values = (keys: Array<string>) => {
      const records = [keys];
      const recordsNumber = (globalThis.SpreadsheetApp as any).getRecordsNumber();
      for (let i = 1; i <= recordsNumber; ++i) {
        records.push(keys.map((key: string) => `${i}${key}/${sheetName}`));
      }
      return records;
    };
    return values(Mock.getBaseRecords(sheetName));
  },
};
```

# テクニカルノート

## コマンド一覧

### `npm run deployments`

GAS のデプロイされている情報を確認します。

```
> clasp-example@1.0.0 deployments
> clasp deployments

2 Deployments.
- AKfycbxAEGZSTBDADb6h5gfA-NM6JxAmZ3_i1BbIm7ibB5g @HEAD 
- AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh @1 - Initial deployment
```

### `npm run deployment`

最新の DEPLOYMENT_ID を確認します。

```
> clasp-example@1.0.0 deployment
> bin/getDeploymentId.sh

AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh
```

### `npm run show`

スプレッドシートや Web API の URL を確認します。

```
> clasp-example@1.0.0 show
> bin/show.sh

- Spreadsheet URL:
https://docs.google.com/spreadsheets/d/1Q6NgOGt9X2PaWMWYsAJf2S6IwisEKODzGNDnELxRa80/edit

- Key-Value JSON:
https://script.google.com/macros/s/AKfycbyI3HuT6aeutoryVNn7F5p1JgutiKMTF9d9xlRiv_IGE5rbNcW0UVsBb5KOSNdFwPIh/exec
```

### `npm run test`

jest のテストを実行します。

```
> clasp-example@1.0.0 test
> jest --passWithNoTests

 PASS  src/Entrypoint/index.spec.ts
 PASS  src/Sheet/index.spec.ts
 PASS  src/global/index.spec.ts
 PASS  src/Util/index.spec.ts
 PASS  src/global/mock.spec.ts
------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------|---------|----------|---------|---------|-------------------
All files   |     100 |      100 |     100 |     100 |                   
 Entrypoint |     100 |      100 |     100 |     100 |                   
  index.ts  |     100 |      100 |     100 |     100 |                   
 Sheet      |     100 |      100 |     100 |     100 |                   
  index.ts  |     100 |      100 |     100 |     100 |                   
 Util       |     100 |      100 |     100 |     100 |                   
  index.ts  |     100 |      100 |     100 |     100 |                   
 global     |     100 |      100 |     100 |     100 |                   
  index.ts  |     100 |      100 |     100 |     100 |                   
  mock.ts   |     100 |      100 |     100 |     100 |                   
------------|---------|----------|---------|---------|-------------------

Test Suites: 5 passed, 5 total
Tests:       30 passed, 30 total
Snapshots:   0 total
Time:        3.376 s, estimated 4 s
Ran all test suites.
```

### `npm run build`

テストの実行後に `src/project.ts` を `dst/project.js` にビルドします。

### `npm run push`

テストの実行後にビルドをして、ビルド結果を GAS に反映させます。

### `npm run force-push`

テストをせずにビルドして、ビルド結果を GAS に反映させます。

一時的なデバッグ用途で用います。

### `npm run deploy`

デプロイ操作のみを実行します。

`push` は事前に行っておく必要があります。

### `npx clasp`

`clasp` コマンドを直接実行する場合は `npx` 経由で実行できます。

- `npx clasp login --status`
    - ログイン状態を確認する
- `npx clasp login`
    - ログインする
- `npx clasp logout`
    - ログアウトする

## `project.js` でグローバル関数を定義するテクニック

通常、GAS ではトップレベルに function 文で関数を定義することによりグローバル関数を定義します。

```
function hello() {
  return "hello";
}
```

トップレベルに定義されたグローバル関数は、GAS エディタ上で実行可能な関数として選択できたり、スプレッドシート上でカスタム関数として利用することができます。

しかし、`esbuild` を経由してトランスパイルすると、以下のように実装が即時実行関数式（IIFE）の中に閉じ込められます。

```
(() => {
  // src/project.ts
  function hello() {
    return "hello";
  }
})();
```

`doGet()` や `onEdit()` のようなイベントハンドラ関数を参照できるようにするためには、トップレベルに function 文でグローバル関数を定義せずとも、グローバルスコープに関数を定義することによって認識されるようになります。

```
(() => {
  // src/project.ts
  function hello() {
    return "hello";
  }
  
  globalThis.hello = hello;
})();
```

グローバルスコープに関数を定義するには、上記のように `globalThis` オブジェクトのプロパティとして関数をセットします。

なお、グローバルスコープを参照するためにトップレベルに `let global = this;` という一文を出力する `esbuild-gas-plugin` というプラグインがありますが、これを利用する必要はありません。そのような出力がしたければ `esbuild` のオプションである `banner` プロパティを利用すればよいですし、上記の通り `globalThis` がグローバル変数として既に定義されているのでそもそもそのような出力をする必要がありません。

ただし、スプレッドシート上で利用可能なカスタム関数として定義するには、グローバルスコープに関数を定義するだけでなくトップレベルに function 文で関数を定義する必要があります。これを実現するため、今回の実装では次のようなハックを行っています。

```
(() => {
  // src/project.ts
  function hello() {
    return "hello";
  }
  
  globalThis.hello = hello;
})();

function hello() {}
```

これによりトップレベルにグローバル関数を定義しつつ、その内部実装は IIFE 内で定義するということを行っています。前者は `bin/build.ts` で、後者は `src/project.ts` で実装しています。

# おわりに

Clasp + esbuild + TypeScript + Jest + Git での GAS 管理手法について、実装コードも全て含めて解説しました。今回のサンプルコードは以下のリポジトリから参照できます。

https://github.com/debiru/clasp

この記事があなたの GAS + Clasp 開発の参考になれば幸いです。