Create your Link in Bio for Bluesky

E2Eとdevコマンド整理 (#216)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by mkizka.dev

Claude Opus 4.5 and committed by
GitHub
f47a0b25 5177ff33

+136 -167
+1
.atproto-version
··· 1 + 595dd20323a9a045105a3e3e2f0e5d4e3dd99b44
+13 -3
.github/workflows/test.yml
··· 24 24 - run: pnpm test -- --coverage 25 25 e2e-test: 26 26 runs-on: ubuntu-latest 27 + permissions: 28 + pull-requests: write 27 29 steps: 28 30 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 29 31 - run: | ··· 34 36 node-version-file: .node-version 35 37 cache: pnpm 36 38 - run: | 37 - ATPROTO_COMMIT=$(cat ./scripts/postinstall.sh | grep ATPROTO_COMMIT= | awk -F'=' '{print $2}') 39 + ATPROTO_COMMIT=$(cat .atproto-version) 38 40 echo "ATPROTO_COMMIT=$ATPROTO_COMMIT" >> $GITHUB_ENV 39 41 NODE_VERSION=$(node -v) 40 42 echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV 41 43 - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 42 44 with: 43 - path: atproto 45 + path: ~/.cache/atproto/${{ env.ATPROTO_COMMIT }} 44 46 key: atproto-${{ env.ATPROTO_COMMIT }}-node-${{ env.NODE_VERSION }} 45 47 - run: pnpm i 46 48 - run: pnpm playwright install --with-deps ··· 58 60 run: | 59 61 E2E_S3_PATH="playwright-report/${{ github.ref == 'refs/heads/main' && 'main' || github.sha }}" 60 62 aws s3 cp playwright-report "s3://$E2E_S3_PATH" --recursive 61 - echo "Report: https://${{ vars.S3_BASE_URL }}/$E2E_S3_PATH/index.html" 63 + echo "E2E_REPORT_URL=${{ vars.S3_BASE_URL }}/$E2E_S3_PATH/index.html" >> $GITHUB_ENV 62 64 env: 63 65 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 64 66 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 65 67 AWS_ENDPOINT_URL: ${{ secrets.AWS_ENDPOINT_URL }} 66 68 AWS_DEFAULT_REGION: auto 69 + - name: Comment on PR 70 + if: always() && github.ref != 'refs/heads/main' && github.actor == 'mkizka' 71 + run: | 72 + BODY="## E2E Test Report 73 + 📊 [View Report]($E2E_REPORT_URL)" 74 + gh pr comment ${{ github.event.number }} --body "$BODY" --edit-last --create-if-none 75 + env: 76 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -1
app/routes/_index.tsx
··· 57 57 {t("_index.edit-link")} 58 58 </Link> 59 59 ) : ( 60 - <Link to="/login" className="btn-bluesky btn w-64 text-base-100"> 60 + <Link 61 + to="/login" 62 + className="btn-bluesky btn w-64 text-base-100" 63 + data-testid="index__login-link" 64 + > 61 65 <AtSymbolIcon className="-ml-4 size-6" /> 62 66 {t("_index.login-link")} 63 67 </Link>
-4
e2e/edit-redirect.spec.ts
··· 1 1 import { test } from "@playwright/test"; 2 2 3 - import { resetStorageState } from "./utils"; 4 - 5 - resetStorageState(); 6 - 7 3 test.describe("編集(リダイレクト)", () => { 8 4 test("非ログイン時はトップにリダイレクト", async ({ page }) => { 9 5 await page.goto("/edit");
+97 -67
e2e/edit.spec.ts
··· 2 2 3 3 test.describe("編集", () => { 4 4 test("カードの編集操作を一通り確認", async ({ page }) => { 5 - // セットアップ 5 + page.on("dialog", (dialog) => dialog.accept()); 6 + 7 + await test.step("ログイン", async () => { 8 + await page.goto("/login"); 9 + await page.getByTestId("login-form__identifier").fill("alice.test"); 10 + await page.getByTestId("login-form__submit").click(); 11 + await page.waitForURL((url) => url.pathname === "/oauth/authorize"); 12 + await page.locator("[name='password']").fill("hunter2"); 13 + await page.locator("button", { hasText: "Sign in" }).click(); 14 + await page.locator("button", { hasText: "Authorize" }).click(); 15 + await page.waitForURL((url) => url.pathname === "/edit"); 16 + }); 17 + 6 18 const text1 = `1. ${crypto.randomUUID()}`; 7 19 const text2 = `2. ${crypto.randomUUID()}`; 8 20 const text1Edited = `1(edit). ${crypto.randomUUID()}`; ··· 15 27 const card1Edited = page.locator('[data-testid="sortable-card"]', { 16 28 hasText: text1Edited, 17 29 }); 18 - await page.goto("/edit"); 19 30 20 - // カードを追加 21 - await page.getByTestId("card-form-modal__button").click(); 22 - await page.getByTestId("card-form__url").fill("https://example.com"); 23 - await page.getByTestId("card-form__text").fill(text1); 24 - await page.getByTestId("card-form__submit").click(); 25 - await expect(card1).toBeVisible(); 31 + await test.step("カードを追加", async () => { 32 + await page.getByTestId("card-form-modal__button").click(); 33 + await page.getByTestId("card-form__url").fill("https://example.com"); 34 + await page.getByTestId("card-form__text").fill(text1); 35 + await page.getByTestId("card-form__submit").click(); 36 + await expect(card1).toBeVisible(); 37 + }); 26 38 27 - // カードを追加(2回目) 28 - await page.getByTestId("card-form-modal__button").click(); 29 - await page.getByTestId("card-form__url").fill("https://example.com"); 30 - await page.getByTestId("card-form__text").fill(text2); 31 - await page.getByTestId("card-form__submit").click(); 32 - await expect(card2).toBeVisible(); 39 + await test.step("カードを追加(2回目)", async () => { 40 + await page.getByTestId("card-form-modal__button").click(); 41 + await page.getByTestId("card-form__url").fill("https://example.com"); 42 + await page.getByTestId("card-form__text").fill(text2); 43 + await page.getByTestId("card-form__submit").click(); 44 + await expect(card2).toBeVisible(); 45 + }); 33 46 34 - // 保存して閲覧ページで順番を確認 35 - await page.getByTestId("board-viewer__submit").click(); 36 - await page.waitForURL((url) => url.pathname !== "/edit"); 37 - await page.getByTestId("show-modal__close").click(); 38 - await expect(card1).toBeVisible(); 39 - await expect(card2).toBeVisible(); 40 - const allCards = await page.getByTestId("sortable-card").allTextContents(); 41 - expect(allCards.indexOf(text1)).toBeLessThan(allCards.indexOf(text2)); 47 + await test.step("保存して閲覧ページで順番を確認", async () => { 48 + await page.getByTestId("board-viewer__submit").click(); 49 + await page.waitForURL((url) => url.pathname !== "/edit"); 50 + await page.getByTestId("show-modal__close").click(); 51 + await expect(card1).toBeVisible(); 52 + await expect(card2).toBeVisible(); 53 + const allCards = await page 54 + .getByTestId("sortable-card") 55 + .allTextContents(); 56 + expect(allCards.indexOf(text1)).toBeLessThan(allCards.indexOf(text2)); 57 + }); 42 58 43 - // カードを並べ替える 44 - await page.getByTestId("profile-card__edit").click(); 45 - await card1 46 - .locator("[data-movable-handle]") 47 - .dragTo(card2, { timeout: 2000 }); 59 + await test.step("カードを並べ替える", async () => { 60 + await page.getByTestId("profile-card__edit").click(); 61 + await card1 62 + .locator("[data-movable-handle]") 63 + .dragTo(card2, { timeout: 2000 }); 64 + }); 48 65 49 - // 保存して閲覧ページで順番を確認 50 - await page.getByTestId("board-viewer__submit").click(); 51 - await page.waitForURL((url) => url.pathname !== "/edit"); 52 - await page.getByTestId("show-modal__close").click(); 53 - await expect(card1).toBeVisible(); 54 - await expect(card2).toBeVisible(); 55 - const sorted = await page.getByTestId("sortable-card").allTextContents(); 56 - expect(sorted.indexOf(text1)).toBeGreaterThan(sorted.indexOf(text2)); 66 + await test.step("保存して閲覧ページで順番を確認", async () => { 67 + await page.getByTestId("board-viewer__submit").click(); 68 + await page.waitForURL((url) => url.pathname !== "/edit"); 69 + await page.getByTestId("show-modal__close").click(); 70 + await expect(card1).toBeVisible(); 71 + await expect(card2).toBeVisible(); 72 + const sorted = await page.getByTestId("sortable-card").allTextContents(); 73 + expect(sorted.indexOf(text1)).toBeGreaterThan(sorted.indexOf(text2)); 74 + }); 57 75 58 - // カードを編集 59 - await page.getByTestId("profile-card__edit").click(); 60 - await card1.getByTestId("sortable-card__edit").click(); 61 - await page.getByTestId("card-form__url").fill("https://example.com"); 62 - await page.getByTestId("card-form__text").fill(text1Edited); 63 - await page.getByTestId("card-form__submit").click(); 76 + await test.step("カードを編集", async () => { 77 + await page.getByTestId("profile-card__edit").click(); 78 + await card1.getByTestId("sortable-card__edit").click(); 79 + await page.getByTestId("card-form__url").fill("https://example.com"); 80 + await page.getByTestId("card-form__text").fill(text1Edited); 81 + await page.getByTestId("card-form__submit").click(); 82 + }); 64 83 65 - // 保存して閲覧ページで順番を確認 66 - await page.getByTestId("board-viewer__submit").click(); 67 - await page.waitForURL((url) => url.pathname !== "/edit"); 68 - await page.getByTestId("show-modal__close").click(); 69 - await expect(card1).not.toBeVisible(); 70 - await expect(card1Edited).toBeVisible(); 71 - await expect(card2).toBeVisible(); 84 + await test.step("保存して閲覧ページで編集を確認", async () => { 85 + await page.getByTestId("board-viewer__submit").click(); 86 + await page.waitForURL((url) => url.pathname !== "/edit"); 87 + await page.getByTestId("show-modal__close").click(); 88 + await expect(card1).not.toBeVisible(); 89 + await expect(card1Edited).toBeVisible(); 90 + await expect(card2).toBeVisible(); 91 + }); 72 92 73 - // カードを削除(後続の削除処理の確認のために1つ残す) 74 - await page.getByTestId("profile-card__edit").click(); 75 - page.on("dialog", (dialog) => dialog.accept()); 76 - await card1Edited.getByTestId("sortable-card__edit").click(); 77 - await page.getByTestId("card-form__delete").click(); 93 + await test.step("カードを削除", async () => { 94 + await page.getByTestId("profile-card__edit").click(); 95 + await card1Edited.getByTestId("sortable-card__edit").click(); 96 + await page.getByTestId("card-form__delete").click(); 97 + }); 78 98 79 - // 保存して閲覧ページで削除を確認 80 - await page.getByTestId("board-viewer__submit").click(); 81 - await page.waitForURL((url) => url.pathname !== "/edit"); 82 - await page.getByTestId("show-modal__close").click(); 83 - await expect(card1Edited).not.toBeVisible(); 99 + await test.step("保存して閲覧ページで削除を確認", async () => { 100 + await page.getByTestId("board-viewer__submit").click(); 101 + await page.waitForURL((url) => url.pathname !== "/edit"); 102 + await page.getByTestId("show-modal__close").click(); 103 + await expect(card1Edited).not.toBeVisible(); 104 + }); 105 + 106 + await test.step("ボードを削除", async () => { 107 + await page.goto("/settings"); 108 + await page.getByTestId("delete-board-button").click(); 109 + }); 84 110 85 - // ボードを削除 86 - await page.goto("/settings"); 87 - await page.getByTestId("delete-board-button").click(); 111 + await test.step("編集ページで削除されていることを確認", async () => { 112 + await page.getByTestId("index__edit-link").click(); 113 + await page.waitForURL("/edit"); 114 + await expect( 115 + page.locator('[data-testid="sortable-card"]'), 116 + ).not.toBeVisible(); 117 + }); 88 118 89 - // 編集ページで削除されていることを確認 90 - await page.getByTestId("index__edit-link").click(); 91 - await page.waitForURL("/edit"); 92 - await expect( 93 - page.locator('[data-testid="sortable-card"]'), 94 - ).not.toBeVisible(); 119 + await test.step("ログアウト", async () => { 120 + await page.goto("/settings"); 121 + await page.getByTestId("logout-button").click(); 122 + await page.waitForURL((url) => url.pathname === "/"); 123 + await expect(page.getByTestId("index__login-link")).toBeVisible(); 124 + }); 95 125 }); 96 126 });
-25
e2e/global.setup.ts
··· 1 - import { test } from "@playwright/test"; 2 - 3 - test.describe("ログイン", () => { 4 - ["alice.test", "bob.test"].forEach((identifier) => { 5 - test(`テストアカウントでログインできる(${identifier})`, async ({ 6 - page, 7 - }) => { 8 - await page.goto("/login"); 9 - await page.getByTestId("login-form__identifier").fill(identifier); 10 - await page.getByTestId("login-form__submit").click(); 11 - 12 - // OAuthログイン 13 - await page.waitForURL((url) => url.pathname === "/oauth/authorize"); 14 - await page.locator("[name='password']").fill("hunter2"); 15 - await page.locator("button", { hasText: "Sign in" }).click(); 16 - await page.locator("button", { hasText: "Authorize" }).click(); 17 - 18 - // ログイン完了 19 - await page.waitForURL((url) => url.pathname === "/edit"); 20 - await page 21 - .context() 22 - .storageState({ path: `./e2e/states/${identifier}.json` }); 23 - }); 24 - }); 25 - });
-21
e2e/logout.spec.ts
··· 1 - import { expect, test } from "@playwright/test"; 2 - 3 - test.describe("ログアウト", () => { 4 - test("ログアウトボタンを押すとダイアログが出てログアウトできる", async ({ 5 - page, 6 - }) => { 7 - await page.goto("/settings"); 8 - page.on("dialog", (dialog) => dialog.accept()); 9 - const logoutButton = page.getByTestId("logout-button"); 10 - await logoutButton.click(); 11 - await expect(logoutButton).not.toBeVisible(); 12 - }); 13 - test("ログアウトボタンを押したあとキャンセルできる", async ({ page }) => { 14 - await page.goto("/settings"); 15 - page.on("dialog", (dialog) => dialog.dismiss()); 16 - const logoutButton = page.getByTestId("logout-button"); 17 - await logoutButton.click(); 18 - await page.waitForTimeout(1000); // 何も起きないことを確認するために待つ 19 - await expect(logoutButton).toBeVisible(); 20 - }); 21 - });
-2
e2e/states/.gitignore
··· 1 - * 2 - !.gitignore
-7
e2e/utils.ts
··· 1 - import { test } from "@playwright/test"; 2 - 3 - export const resetStorageState = () => { 4 - test.use({ 5 - storageState: { cookies: [], origins: [] }, 6 - }); 7 - };
+1 -1
package.json
··· 13 13 "build:remix": "react-router build", 14 14 "build:server": "node ./scripts/build-server.js", 15 15 "dev": "./scripts/dev.sh | pino-pretty", 16 - "dev-atproto": "cd atproto && make run-dev-env", 16 + "dev-atproto": "./scripts/dev-atproto.sh", 17 17 "e2e": "playwright test", 18 18 "format": "pnpm _eslint --fix && prettier . --write", 19 19 "lint": "pnpm _eslint && prettier . --check",
+1 -27
playwright.config.ts
··· 1 - import { defineConfig, devices } from "@playwright/test"; 1 + import { defineConfig } from "@playwright/test"; 2 2 3 3 export default defineConfig({ 4 4 testDir: "./e2e", 5 5 outputDir: "./node_modules/.cache/playwright", 6 - fullyParallel: true, 7 6 forbidOnly: !!process.env.CI, 8 7 retries: process.env.CI ? 2 : 0, 9 8 reporter: [ ··· 15 14 video: "on", 16 15 trace: "on", 17 16 }, 18 - projects: [ 19 - // https://playwright.dev/docs/auth 20 - { 21 - name: "setup", 22 - testMatch: "global.setup.ts", 23 - }, 24 - { 25 - name: "safari", 26 - use: { 27 - ...devices["iPhone 15 Pro"], 28 - locale: "ja-JP", 29 - storageState: "./e2e/states/bob.test.json", 30 - }, 31 - dependencies: ["setup"], 32 - }, 33 - { 34 - name: "chromium", 35 - use: { 36 - ...devices["Desktop Chrome"], 37 - locale: "ja-JP", 38 - storageState: "./e2e/states/alice.test.json", 39 - }, 40 - dependencies: ["setup"], 41 - }, 42 - ], 43 17 webServer: { 44 18 command: "pnpm start:local", 45 19 port: 3000,
+7
scripts/dev-atproto.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + ATPROTO_COMMIT=$(cat .atproto-version) 5 + ATPROTO_DIR="$HOME/.cache/atproto/$ATPROTO_COMMIT" 6 + 7 + cd "$ATPROTO_DIR" && make run-dev-env
+5 -6
scripts/postinstall.sh
··· 1 1 #!/usr/bin/env bash 2 2 set -euo pipefail 3 3 4 - # https://github.com/bluesky-social/atproto/commits/@atproto/pds@0.4.204 5 - ATPROTO_COMMIT=c2615a7eee6da56a43835adb09c5901a1872efd3 4 + ATPROTO_COMMIT=$(cat .atproto-version) 5 + ATPROTO_DIR="$HOME/.cache/atproto/$ATPROTO_COMMIT" 6 6 7 7 # git submoduleを使うとDockerビルド中に動作しないため、gigetを使ってatprotoを取得する 8 - if [ ! -d atproto ]; then 9 - # https://github.com/bluesky-social/atproto/commit/f2f8de63b333448d87c364578e023ddbb63b8b25 10 - pnpm giget gh:bluesky-social/atproto#$ATPROTO_COMMIT atproto 8 + if [ ! -d "$ATPROTO_DIR" ]; then 9 + pnpm giget gh:bluesky-social/atproto#$ATPROTO_COMMIT "$ATPROTO_DIR" 11 10 fi 12 11 mkdir -p ./lexicons/com/atproto 13 - cp -r ./atproto/lexicons/com/atproto/repo ./lexicons/com/atproto 12 + cp -r "$ATPROTO_DIR/lexicons/com/atproto/repo" ./lexicons/com/atproto 14 13 15 14 LEXICONS=$(find ./lexicons -name '*.json' -type f) 16 15 echo y | pnpm lex gen-api ./app/generated/api $LEXICONS
+6 -3
scripts/setup-dev.sh
··· 1 1 #!/usr/bin/env bash 2 2 set -euo pipefail 3 3 4 + ATPROTO_COMMIT=$(cat .atproto-version) 5 + ATPROTO_DIR="$HOME/.cache/atproto/$ATPROTO_COMMIT" 6 + 4 7 # 1. Setup atproto dev server 5 - if [ ! -d ./atproto/node_modules ]; then 6 - cd atproto 8 + if [ ! -d "$ATPROTO_DIR/node_modules" ]; then 9 + cd "$ATPROTO_DIR" 7 10 make deps 8 11 make build 9 - cd .. 12 + cd - 10 13 fi 11 14 12 15 # 2. Start atproto dev server