【GAS】GoogleAppsScriptによるWebアプリ構築ボイラープレートを作成しました

![header.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219708/6d546d5d-c71a-fdd7-3bd3-d990d0b3db70.png)

# 概要

GoogleAppsScriptでWebアプリを作成するための環境を構築しました。
もう一度同じものを作るためには手順が多かったため、ボイラープレートとして再利用できるようにしました。
DevContainer、React、TypeScriptあたりを使用し、VSCodeでの開発を想定しています。

https://github.com/Mikoshiba-Kyu/gas-webapp-boilerplate

# 構築手順

構築手順は以下の通りなので、好みに応じてアレンジしてみるのも良いかと思います。

## 1. DevContainer の定義

プロジェクトのフォルダを作成し、直下に `.devcontainer` フォルダを作成。
その中に `devcontainer.json` を作成して内容を以下のようにします。

``` json
{
  "name": "container name",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
  "customizations": {
    "vscode": {
      "extensions": ["esbenp.prettier-vscode"]
    }
  },
  "postCreateCommand": "yarn && yarn global add @google/clasp"
}
```

VSCodeの場合の拡張機能はとりあえず Prettier だけ入れました。
`postCreateCommand` ではパッケージのインストールを行いますが、`yarn global add @google/clasp` で clasp のインストールも定義しています。

以降の作業は DevContainer を起動して行っていきます。
この辺で `git init` もしておきます。

:::note info
clasp を global で入れるか否かについては色々あるようですが、今回は何も考えずに global インストールしています。コンテナ内なので global で問題ないと思っています。
:::

:::note info
全体を通して `yarn` を使用していますが特に理由はないので `npm` でも問題ありません。
:::


## 2. Vite による React の導入

`yarn create vite` で React + TypeScript の導入を行います。

``` bash
$ yarn create vite

? Project name: your project name # プロジェクトのフォルダ名と合わせておくと良いです
? Select a framework: React
? Select a variant: TypeScript + SWC

$ cd your project name
$ yarn
$ yarn dev
```

とりあえずの起動確認ができました。
![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219708/510784f2-acf8-c7ba-3022-65c1677356f8.png)

### フォルダ構成の変更

今作成したプロジェクトフォルダの中身をすべてプロジェクトのルートに移動し、空になったフォルダは削除します。

![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219708/2795e73a-02c2-0eee-d5e5-fb84319a798c.png)

次に現在の `src` フォルダの中身を `frontend` フォルダにまとめておきます。
ついでに `backend` フォルダも作成しておきます。

`root/public` 内の `vite.svg` は `src/frontend/assets` に移動し、フォルダは削除してしまいます。

![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219708/d518c61f-2453-f2b6-e299-ce7ba9c61a2c.png)

構成を変更したので一部のファイルを修正します。

``` diff_html:index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
-    <script type="module" src="/src/main.tsx"></script>
+    <script type="module" src="/src/frontend/main.tsx"></script>
  </body>
</html>
```

``` diff_tsx:App.tsx
import { useState } from 'react'
import reactLogo from './assets/react.svg'
- import viteLogo from '/vite.svg'
+ import viteLogo from './assets/vite.svg'
import './App.css'
```

ここまでで `yarn dev` を実行すると先ほどと同じ画面が表示されます。

## 3. 各種パッケージのインストール

GoogleAppsScriptの開発に関係する以下のパッケージをインストールします。

``` bash
$ yarn global add @google/clasp
$ yarn add --dev @types/google-apps-script
$ yarn add --dev vite-plugin-singlefile
$ yarn add --dev rollup-plugin-google-apps-script
$ yarn add gas-client
```
## 4. デプロイするファイルの出力ディレクトリを作成

`yarn build` で最終的なファイルを出力するディレクトリを作成します。
プロジェクトルートに `gas` フォルダを作成します。
最終的には `gas/dist` にファイルが出力されます。

デプロイ時に必要となる `appsscript.json` も `gas` の直下に作成しておきます。

``` json:appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "MYSELF"
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}
```

https://developers.google.com/apps-script/manifest?hl=ja

## 5. バックエンドのサンプル関数を作成

`src/backend` に `serverFunctions` フォルダを作成します。
中に `index.ts` を作成し、内容を以下のようにします。
受け取った数値をプラス1して返すだけのサンプル関数です。

``` typescript:index.ts
export const sampleFunction = (num: number): number => num + 1;
```

`src/backend` 直下に `main.ts` を作成し、内容を以下のようにします。

``` typescript:main.ts
import { sampleFunction } from "./serverFunctions";

declare const global: {
  [x: string]: unknown;
};

// This function is required to run as a webApp
global.doGet = (): GoogleAppsScript.HTML.HtmlOutput => {
  return HtmlService.createHtmlOutputFromFile("dist/index.html");
};

// Create the necessary functions below.
global.sampleFunction = sampleFunction;
```

`global.doGet` は GoogleAppsScript でWebアプリを立ち上げるために必要な関数です。
`global.sampleFunction` のように関数をグローバルに定義し、フロントエンド側から呼び出すことができるようになりました。

Vite+Reactのスタートページではサンプルとしてクリックするとカウントアップするボタンが設置されています。
Webアプリを立ち上げた際には、先程作成した `sampleFunction` を使用してカウントアップするようにしてみます。

`src/frontend` の `App.tsx` を以下のようにします。

``` diff_tsx:App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "./assets/vite.svg";
import "./App.css";
+ import { GASClient } from "gas-client";
+ const { serverFunctions } = new GASClient();

function App() {
  const [count, setCount] = useState(0);

+ const handleButton = async () => {
+   if (import.meta.env.PROD) {
+     try {
+       const response: number = await serverFunctions.sampleFunction(count);
+       setCount(response);
+     } catch (err) {
+       console.log(err);
+     }
+   } else {
+     setCount(count + 1);
+   }
+ };
  
  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
-       <button onClick={() => setCount((count) => count + 1)}>
-         count is {count}
-       </button>
+       <button onClick={handleButton}>count is {count}</button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;
```

:::note info
`import.meta.env.PROD` によって、バックエンドの関数を使用するか否かの判定を行っています。
この値は `yarn build` した場合 `true`、`yarn dev` の場合は `false` を取ります。
ローカルデバッグ時は GoogleAppsScript のクラスは使用できないため、このように環境に応じて動作を分けておく必要が出てきます。
:::


## 6. ビルドの設定

アプリとしての構成は出来たので、ビルドの設定を行っていきます。
最終的にフロントエンドは単一の `html` ファイル、バックエンドは単一の `js` ファイルとなるようにします。

それぞれ個別にビルドする必要があるため、`vite.config.ts` ファイルをコピーし、
`vite.config.frontend.ts` と `vite.config.backend.ts` を作成します。

``` typescript:vite.config.frontend.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [react(), viteSingleFile()],
  build: {
    outDir: "gas/dist",
    emptyOutDir: false,
  },
});
```

フロントエンドは vite-plugin-singlefile を使用して単一ファイルを生成します。

https://github.com/richardtallent/vite-plugin-singlefile

``` typescript:vite.config.backend.ts
import { defineConfig } from "vite";
import rollupPluginGas from "rollup-plugin-google-apps-script";

export default defineConfig({
  plugins: [rollupPluginGas()],
  build: {
    rollupOptions: {
      input: "src/backend/main.ts",
      output: {
        dir: "gas/dist",
        entryFileNames: "main.js",
      },
    },
    emptyOutDir: false,
    minify: false,
  },
});
```

バックエンドは rollup-plugin-google-apps-script を使用して単一ファイルを生成します。

https://github.com/mato533/rollup-plugin-gas

どちらも `emptyOutDir: false` として、フォルダの初期化はスクリプト側で行います。
`package.json` の `"scripts"` を定義します。

``` diff_javascript:package.json
  "scripts": {
    "dev": "vite",
-   "build": "tsc -b && vite build",
+   "clean": "rimraf gas/dist",
+   "build:frontend": "vite build --config vite.config.frontend.ts",
+   "build:backend": "vite build --config vite.config.backend.ts",
+   "build": "yarn clean && yarn build:frontend && yarn build:backend",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  }
```

`yarn build` を実行すると `gas/dist` フォルダ内にファイルが生成されます。
![04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219708/7ffbe977-11e1-e8e4-2201-76405ac5e2e9.png)

ここで作成される `gas/dist` は `.gitignore` に追加して除外しておくのが良いです。

``` diff:.gitignore
+ # clasp
+ gas/dist
```

## 7. デプロイする

まず clasp にログインします。
以下のコマンドにより開くブラウザ上で認証を行います。

``` bash
$ clasp login
```

次にデプロイ先のプロジェクトファイルを作成します。
この方法で作成したプロジェクトファイルは Google Drive のルートに配置されます。

``` bash
$ clasp create

? Create which script?
  standalone
  docs
  sheets
  slides
  forms
> webapp
  api
```

プロジェクトを作成すると `appscript.json` と `.clasp.json` がプロジェクトルートに作成されます。
このボイラープレートでは `appscript.json` は `gas` フォルダにあらかじめ配置しているため、
新たに作成されたものは削除してしまっても良いです。

`.clasp.json` の `"rootDir"` はアップロードする対象のフォルダを指すので変更しておきます。

```diff_javascript:.clasp.json
{
  "scriptId": "xxxxxxxx",
-  "rootDir": "/workspaces/your project name"
+  "rootDir": "/workspaces/your project name/gas"
}
```

また、`.clasp.json` は `"scriptId"` の情報を含んでいるため、こちらも `.gitignore` に追加しておきます。

``` diff:.gitignore
# clasp
gas/dist
.clasp.json
```

最後にデプロイを行います。

``` bash
$ yarn build # 何か変更があれば
$ clasp push
$ clasp deploy
```

以降は開発サイクルとして変更するたびに上記のコマンドを実行していく流れとなります。

https://github.com/google/clasp

# テスト関連

## 1. Vitest を導入する

ユニットテストを整備するために **Vitest** を導入します。

``` bash
$ yarn add -D vitest
```

ユニットテストを実行するため、`package.json` の `"scripts"` を定義します。

``` diff_javascript:package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "clean": "rimraf gas/dist",
    "build:frontend": "vite build --config vite.config.frontend.ts",
    "build:backend": "vite build --config vite.config.backend.ts",
    "build": "yarn clean && yarn build:frontend && yarn build:backend",
+   "test:unit": "vitest",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  }
```

サンプルとして作った関数に対して疎通確認してみます。
`src/backend/serverFunctions` に `index.spec.ts` を作成し、雑ですが内容を以下のようにします。

``` Typescript:index.spec.ts
import { expect, test } from "vitest";
import { sampleFunction } from "./index";

test("If 1 is given, 2 is returned.", () => {
  expect(sampleFunction(1)).toBe(2);
});
```

`vite.config.ts` の内容を以下のようにします。
後ほど Playwright を導入し、そのテストコードは `e2e` フォルダに作成していきます。
Vitest の実行時に Playwright のテストが実行されないように `exclude` に `e2e` フォルダを指定しています。

```diff_typescript:vite.config.ts
+ /// <reference types="vitest" />
  import { defineConfig } from "vite";
  import react from "@vitejs/plugin-react-swc";

  export default defineConfig({
    plugins: [react()],
+   test: {
+     exclude: [
+       "**/node_modules/**",
+       "**/dist/**",
+       "**/cypress/**",
+       "**/.{idea,git,cache,output,temp}/**",
+       "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*",
+       "**/e2e/**",
      ],
    },
  });
```

Vitestの拡張機能を導入するため `devcontainer.json` を更新しておきます。

``` diff_javascript: devcontainer.json
{
  "name": "container name",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
  "customizations": {
    "vscode": {
-     "extensions": ["esbenp.prettier-vscode"]
+     "extensions": [
+       "esbenp.prettier-vscode",
+       "vitest.explorer"
+     ]
    }
  },
  "postCreateCommand": "yarn && yarn global add @google/clasp"
}
```

`yarn test:unit` を実行するとユニットテストが開始されパスしました。

```
 ✓ src/backend/serverFunctions/index.spec.ts (1)
   ✓ If 1 is given, 2 is returned.

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  13:19:49
   Duration  2.37s (transform 729ms, setup 0ms, collect 820ms, tests 1ms, environment 0ms, prepare 1.15s)
```

## 2. Playwright を導入する

E2Eテストを整備するために **Playwright** を導入します。

``` bash
$ yarn create playwright

> Where to put your end-to-end tests? : e2e
> Add a GitHub Actions workflow? (y/N) : true
> Install Playwright browsers (can be done manually via 'yarn playwright install')? (Y/n) : true

yarn create playwright
```

コンテナ作成時に `yarn playwright install --with-deps` を実行するように `devcontainer.json` を更新します。
ついでに Playwright の拡張機能も導入しておきます。

``` diff_javascript: devcontainer.json
{
  "name": "container name",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
  "customizations": {
    "vscode": {
      "extensions": [
        "esbenp.prettier-vscode",
        "vitest.explorer",
+       "ms-playwright.playwright"
      ]
    }
  },
- "postCreateCommand": "yarn && yarn global add @google/clasp"
+ "postCreateCommand": "yarn && yarn global add @google/clasp && yarn playwright install --with-deps"
}
```

E2Eテストを実行するため、`package.json` の `"scripts"` を定義します。

``` diff_javascript:package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "clean": "rimraf gas/dist",
    "build:frontend": "vite build --config vite.config.frontend.ts",
    "build:backend": "vite build --config vite.config.backend.ts",
    "build": "yarn clean && yarn build:frontend && yarn build:backend",
    "test:unit": "vitest",
+   "test:e2e": "playwright test",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  }
```

Github Actions から任意のタイミングで実行できるようにしておきます。
`.github/workflows`の `playwright.yml` を編集します。

``` diff_yaml: playwright.yml
name: Playwright Tests
on:
- push:
-   branches: [ main, master ]
- pull_request:
-   branches: [ main, master ]
+ workflow_dispatch:
+   inputs:
+     baseURL:
+       description: 'Base URL for the tests'
+       required: true
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
      - name: Install dependencies
        run: npm install -g yarn && yarn
      - name: Install Playwright Browsers
        run: yarn playwright install --with-deps
      - name: Run Playwright tests
        run: yarn playwright test
+       env:
+         PLAYWRIGHT_BASE_URL: ${{ github.event.inputs.baseURL }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
```

`playwright.config.ts` の `baseURL` を調整します。
Github Actions から実行する際は `PLAYWRIGHT_BASE_URL` を外から指定する形にします。

``` javascript
use: {
  baseURL: process.env.PLAYWRIGHT_BASE_URL || 'your apps url',
}
```

適当なテストを作成して疎通確認してみます。
`e2e` 直下の `example.spec.ts` の内容を以下のようにします。
Vite のスタートページにあるカウントアップボタンをクリックすると結果が1になる、というテストです。

``` typescript: example.spec.ts
import { test, expect, Locator, FrameLocator } from "@playwright/test";

test("Clicking on it makes the count go to 1.", async ({ page }) => {
  await page.goto("");

  const appFrame: FrameLocator = page
    .frameLocator("#sandboxFrame")
    .frameLocator("#userHtmlFrame");

  if (!appFrame) {
    throw new Error("appFrame is not found");
  }

  const countUpButton: Locator = appFrame!.getByRole("button");
  await countUpButton.click();

  await expect(countUpButton).toContainText("1");
});
```

:::note info
Google Apps Script にデプロイされたアプリは iframe の中に描画されます。
そのため、まずは FrameLocator を取得し、その中の要素に対して操作を行う必要があります。
:::

デプロイしたアプリの公開範囲を全体に設定し、`baseURL` を公開URLに変更した状態で、
`yarn test:e2e` を実行するとE2Eテストが開始されパスしました。

```
Running 3 tests using 3 workers
  3 passed (10.9s)

To open last HTML report run:

  yarn playwright show-report

Done in 18.62s.
```

# まとめ

このように、Google Apps Script で Webアプリの構築を行い、意外と色々できそうな気配はありました。
ただ、環境構築という点では難なく動いていますが、特殊な環境であるため作り込んでいくと壁にぶつかる瞬間がやってくる気もします。
ここまで書いておいてなんですが、結果としては、Google Apps Script でこういうことはやるべきではない、やる意味がない、という感想でした。