@ripla/godd-mcp 1.0.6 → 1.0.7-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/godd.cjs +194 -185
  2. package/dist/godd.js +191 -49
  3. package/dist/index.js +1 -1
  4. package/notes/api/.env.example +1 -4
  5. package/notes/api/README.md +4 -2
  6. package/notes/api/app/config.py +8 -5
  7. package/notes/api/app/routers/tree.py +3 -3
  8. package/notes/api/app/services/github.py +14 -13
  9. package/notes/api/app/services/github_app_token.py +10 -15
  10. package/notes/api/app/services/github_pr.py +14 -8
  11. package/notes/api/tests/test_files.py +2 -2
  12. package/notes/api/tests/test_github_app_token.py +6 -5
  13. package/notes/api/tests/test_github_service.py +30 -4
  14. package/notes/api/tests/test_pr_chain.py +25 -2
  15. package/notes/api/tests/test_tree.py +1 -1
  16. package/notes/app/e2e/pages/login-page.ts +6 -0
  17. package/notes/app/e2e/password-toggle.spec.ts +198 -0
  18. package/notes/app/src/components/PasswordInput.test.tsx +122 -0
  19. package/notes/app/src/components/PasswordInput.tsx +67 -0
  20. package/notes/app/src/pages/LoginPage.test.tsx +31 -1
  21. package/notes/app/src/pages/LoginPage.tsx +14 -8
  22. package/notes/app/src/pages/UserManagementPage.test.tsx +51 -0
  23. package/notes/app/src/pages/UserManagementPage.tsx +2 -2
  24. package/package.json +1 -1
  25. package/templates/github-actions/aws/deploy-notes.yml.hbs +31 -0
  26. package/templates/notes-compose.yml +0 -1
  27. package/templates/notes-docs/007_guides/setup_godd.md +46 -10
  28. package/templates/notes-docs/007_guides/setup_godd_notes.md +218 -16
  29. package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +409 -0
  30. package/templates/notes-docs/007_guides/setup_infra.template.md +75 -22
  31. package/templates/terraform/aws/ecs.tf.hbs +9 -1
  32. package/templates/terraform/aws/provider.tf.hbs +1 -1
  33. package/templates/terraform/aws/secrets.tf.hbs +11 -3
@@ -1,7 +1,4 @@
1
- # GitHub API token (PAT — omitted for mock mode; superseded by GitHub App below)
2
- # GITHUB_TOKEN=
3
-
4
- # GitHub App credentials (recommended over PAT for bot authorship of PRs/Issues)
1
+ # GitHub App credentials (required for live GitHub API; PAT / GITHUB_TOKEN is forbidden)
5
2
  # GITHUB_APP_ID=
6
3
  # GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----
7
4
  # GITHUB_APP_INSTALLATION_ID=
@@ -7,7 +7,7 @@ FastAPI バックエンド。docs/ のドキュメント閲覧・編集、GitHub
7
7
  - Python 3.12+
8
8
  - uv
9
9
  - PostgreSQL 16(GoDD Registry と共有)
10
- - GitHub Token(モックモードなら不要)
10
+ - GitHub App credentials(`GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` / `GITHUB_APP_INSTALLATION_ID`)— 本番 GitHub 連携時。未設定時はモックモード
11
11
 
12
12
  ## セットアップ
13
13
 
@@ -39,10 +39,10 @@ uv run python -m app.main
39
39
 
40
40
  | 変数 | 必須 | デフォルト | 説明 |
41
41
  |------|------|-----------|------|
42
- | GITHUB_TOKEN | - | (未設定=Mock) | GitHub API PAT(任意。GitHub App 未設定時の write 操作フォールバック) |
43
42
  | GITHUB_APP_ID | **Yes** (write) | - | GitHub App ID。PR / Issue 作成時の Bot author に必須 |
44
43
  | GITHUB_APP_PRIVATE_KEY | **Yes** (write) | - | GitHub App private key PEM |
45
44
  | GITHUB_APP_INSTALLATION_ID | **Yes** (write) | - | GitHub App installation ID |
45
+ | GITHUB_TOKEN | — | — | **禁止(無視)**。PAT は使用不可。設定しても Notes API は参照しない |
46
46
  | GITHUB_OWNER | - | ripla-inc | リポジトリオーナー |
47
47
  | GITHUB_REPO | - | GoDD | リポジトリ名 |
48
48
  | GITHUB_BRANCH | - | main | ベースブランチ |
@@ -52,6 +52,8 @@ uv run python -m app.main
52
52
  | NOTES_ADMIN_USERNAME | - | admin | 初期管理者 |
53
53
  | NOTES_ADMIN_PASSWORD | - | admin123 | 初期管理者パスワード |
54
54
 
55
+ GitHub App 3変数がすべて未設定の場合、Notes API は **モックモード**(GitHub API を呼ばない)で動作します。`GITHUB_TOKEN`(PAT)を設定しても **無視されます**(禁止)。
56
+
55
57
  ## API
56
58
 
57
59
  - `GET /api/health` - ヘルスチェック(`github_app_configured`, `github_write_author`)
@@ -73,8 +73,8 @@ class Settings(BaseSettings):
73
73
  jwt_access_expires_min: int = Field(default=15, ge=1, le=1440)
74
74
  jwt_refresh_expires_days: int = Field(default=7, ge=1, le=365)
75
75
 
76
- # GitHub
77
- github_token: str | None = None
76
+ # GitHub (PAT / GITHUB_TOKEN is forbidden — ignored if set)
77
+ github_token: str | None = None # deprecated: ignored; use GitHub App only
78
78
  github_owner: str = "ripla-inc"
79
79
  github_repo: str = "GoDD"
80
80
  github_branch: str = "main"
@@ -150,9 +150,7 @@ class Settings(BaseSettings):
150
150
  )
151
151
 
152
152
  def is_mock_mode(self) -> bool:
153
- """True when neither GITHUB_TOKEN nor GitHub App credentials are set."""
154
- if self.github_token:
155
- return False
153
+ """True when GitHub App credentials are not set (PAT is ignored/forbidden)."""
156
154
  return not (
157
155
  self.github_app_id
158
156
  and self.github_app_private_key
@@ -176,6 +174,11 @@ class Settings(BaseSettings):
176
174
  warnings.append("DB_PASSWORD is using the default dev value")
177
175
  if self.allowed_origins == "*":
178
176
  warnings.append("ALLOWED_ORIGINS is set to '*' (all origins)")
177
+ if self.github_token:
178
+ warnings.append(
179
+ "GITHUB_TOKEN is set but ignored — PAT is forbidden; "
180
+ "use GitHub App credentials only"
181
+ )
179
182
  for w in warnings:
180
183
  _logger.warning("SECURITY: %s — override via environment variable for production", w)
181
184
  return self
@@ -19,12 +19,12 @@ async def get_tree(
19
19
  ref: str | None = None,
20
20
  _payload: dict = Depends(auth_dependency),
21
21
  ):
22
- """Get file tree. Pass *ref* to read a specific branch. Mock mode when GITHUB_TOKEN not set."""
22
+ """Get file tree. Pass *ref* to read a specific branch. Mock mode when GitHub App is not set."""
23
23
  if settings.is_mock_mode():
24
24
  return {
25
25
  "mode": "mock",
26
26
  "source": "mock",
27
- "reason": "GITHUB_TOKEN が未設定です",
27
+ "reason": "GitHub App が未設定です",
28
28
  "tree": get_mock_tree(),
29
29
  }
30
30
 
@@ -38,7 +38,7 @@ async def get_tree(
38
38
  logger.warning("GitHub API HTTP error %d for tree: %s", code, e)
39
39
  if code in (401, 403):
40
40
  detail = (
41
- "GitHub トークンの権限が不足しています。"
41
+ "GitHub App の権限が不足しています。"
42
42
  "Organization の承認が必要な場合があります。"
43
43
  )
44
44
  elif code == 404:
@@ -18,15 +18,15 @@ def _encode_path(path: str) -> str:
18
18
 
19
19
 
20
20
  async def resolve_token(config: dict) -> str:
21
- """Return a valid GitHub token: prefer App installation token, fallback to PAT."""
21
+ """Return a GitHub App installation token. PAT is not allowed."""
22
22
  from app.services.github_app_token import get_installation_token, is_app_configured
23
23
 
24
- if is_app_configured():
25
- return await get_installation_token()
26
- token = config.get("token")
27
- if not token:
28
- raise ValueError("No GitHub token available (neither PAT nor App configured)")
29
- return token
24
+ if not is_app_configured():
25
+ raise ValueError(
26
+ "GitHub App credentials are required. "
27
+ "Personal access tokens (GITHUB_TOKEN / PAT) are not allowed."
28
+ )
29
+ return await get_installation_token()
30
30
 
31
31
 
32
32
  def _auth_headers(token: str) -> dict[str, str]:
@@ -54,13 +54,14 @@ def get_github_config() -> dict:
54
54
  """Get GitHub config from settings."""
55
55
  from app.services.github_app_token import is_app_configured
56
56
 
57
- if not settings.github_token and not is_app_configured():
57
+ if not is_app_configured():
58
58
  raise ValueError(
59
- "GITHUB_TOKEN or GitHub App credentials are required. "
60
- "Set them in .env or environment variables."
59
+ "GitHub App credentials are required. "
60
+ "Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and "
61
+ "GITHUB_APP_INSTALLATION_ID. Personal access tokens (PAT) are not allowed."
61
62
  )
62
63
  return {
63
- "token": settings.github_token or "",
64
+ "token": "",
64
65
  "owner": settings.github_owner,
65
66
  "repo": settings.github_repo,
66
67
  "branch": settings.github_branch,
@@ -104,8 +105,8 @@ def get_mock_file_content(file_path: str) -> dict:
104
105
  "content": (
105
106
  f"# {file_path}\n\n"
106
107
  "このファイルはモックデータです。\n\n"
107
- "GITHUB_TOKEN を設定すると、実際のリポジトリから"
108
- "ドキュメントが読み込まれます。"
108
+ "GitHub App(GITHUB_APP_ID / PRIVATE_KEY / INSTALLATION_ID)を"
109
+ "設定すると、実際のリポジトリからドキュメントが読み込まれます。"
109
110
  ),
110
111
  "sha": "mock-sha",
111
112
  "size": 100,
@@ -13,7 +13,7 @@ from app.config import settings
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
- GitHubWriteAuthor = Literal["bot", "pat", "mock"]
16
+ GitHubWriteAuthor = Literal["bot", "mock"]
17
17
 
18
18
  _TOKEN_BUFFER_SECONDS = 300
19
19
  _JWT_EXPIRY_SECONDS = 600
@@ -35,30 +35,25 @@ def github_write_author_mode() -> GitHubWriteAuthor:
35
35
  """How PR/Issue creation will attribute authorship."""
36
36
  if is_app_configured():
37
37
  return "bot"
38
- if settings.github_token:
39
- return "pat"
40
38
  return "mock"
41
39
 
42
40
 
43
- def warn_if_pat_used_for_write(operation: str) -> None:
44
- """Warn when a GitHub write will fall back to PAT instead of GitHub App."""
41
+ def warn_if_github_app_missing(operation: str) -> None:
42
+ """Warn when a GitHub write cannot use GitHub App (PAT is forbidden)."""
45
43
  if is_app_configured():
46
44
  return
47
- if settings.github_token:
48
- logger.warning(
49
- "GitHub App is not configured. %s will be created under the "
50
- "personal account of the GITHUB_TOKEN owner. "
51
- "Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and "
52
- "GITHUB_APP_INSTALLATION_ID to use the Bot account as author.",
53
- operation,
54
- )
55
- return
56
45
  logger.warning(
57
- "GitHub App is not configured and GITHUB_TOKEN is empty. %s may fail.",
46
+ "GitHub App is not configured. %s will fail or run in mock mode. "
47
+ "Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and "
48
+ "GITHUB_APP_INSTALLATION_ID. Personal access tokens (PAT) are not allowed.",
58
49
  operation,
59
50
  )
60
51
 
61
52
 
53
+ # Backward-compatible alias (PAT path removed; always warns when App missing).
54
+ warn_if_pat_used_for_write = warn_if_github_app_missing
55
+
56
+
62
57
  def _generate_jwt() -> str:
63
58
  now = int(time.time())
64
59
  payload = {
@@ -18,10 +18,13 @@ logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
20
  def _config() -> dict:
21
- if not settings.github_token and not is_app_configured():
22
- raise ValueError("GITHUB_TOKEN or GitHub App credentials required")
21
+ if not is_app_configured():
22
+ raise ValueError(
23
+ "GitHub App credentials required. "
24
+ "Personal access tokens (PAT) are not allowed."
25
+ )
23
26
  return {
24
- "token": settings.github_token,
27
+ "token": "",
25
28
  "owner": settings.github_owner,
26
29
  "repo": settings.github_repo,
27
30
  "branch": settings.github_branch,
@@ -31,10 +34,13 @@ def _config() -> dict:
31
34
 
32
35
 
33
36
  async def _resolve_token(config: dict) -> str:
34
- """Return the GitHub App installation token if configured, else the PAT."""
35
- if is_app_configured():
36
- return await get_installation_token()
37
- return config["token"]
37
+ """Return the GitHub App installation token. PAT is not allowed."""
38
+ if not is_app_configured():
39
+ raise ValueError(
40
+ "GitHub App credentials required. "
41
+ "Personal access tokens (PAT) are not allowed."
42
+ )
43
+ return await get_installation_token()
38
44
 
39
45
 
40
46
  def _headers_with_token(token: str) -> dict:
@@ -47,7 +53,7 @@ def _headers_with_token(token: str) -> dict:
47
53
 
48
54
 
49
55
  async def _resolved_headers(config: dict) -> dict:
50
- """Resolve token (App or PAT) and return auth headers."""
56
+ """Resolve GitHub App installation token and return auth headers."""
51
57
  token = await _resolve_token(config)
52
58
  return _headers_with_token(token)
53
59
 
@@ -15,7 +15,7 @@ class TestGetFile:
15
15
  self._headers = auth_headers(viewer_token)
16
16
 
17
17
  async def test_get_file_mock_mode(self, client: AsyncClient):
18
- """Without GITHUB_TOKEN, returns mock file content."""
18
+ """Without GitHub App credentials, returns mock file content."""
19
19
  resp = await client.get("/api/files/docs/README.md", headers=self._headers)
20
20
  assert resp.status_code == 200
21
21
  data = resp.json()
@@ -49,7 +49,7 @@ class TestGetHistory:
49
49
  self._headers = auth_headers(viewer_token)
50
50
 
51
51
  async def test_history_mock_mode(self, client: AsyncClient):
52
- """Without GITHUB_TOKEN, history returns empty list."""
52
+ """Without GitHub App credentials, history returns empty list."""
53
53
  resp = await client.get("/api/history/docs/README.md", headers=self._headers)
54
54
  assert resp.status_code == 200
55
55
  assert resp.json() == []
@@ -50,9 +50,10 @@ class TestGithubWriteAuthorMode:
50
50
 
51
51
  @patch("app.services.github_app_token.is_app_configured", return_value=False)
52
52
  @patch("app.services.github_app_token.settings")
53
- def test_pat_when_token_only(self, mock_settings, _mock_app):
53
+ def test_mock_when_pat_only_pat_forbidden(self, mock_settings, _mock_app):
54
+ """PAT alone must not enable write mode — mock only."""
54
55
  mock_settings.github_token = "ghp_test"
55
- assert github_write_author_mode() == "pat"
56
+ assert github_write_author_mode() == "mock"
56
57
 
57
58
  @patch("app.services.github_app_token.is_app_configured", return_value=False)
58
59
  @patch("app.services.github_app_token.settings")
@@ -133,13 +134,13 @@ class TestWarnIfPatUsedForWrite:
133
134
 
134
135
  @patch("app.services.github_app_token.is_app_configured", return_value=False)
135
136
  @patch("app.services.github_app_token.settings")
136
- def test_warns_when_pat_fallback(self, mock_settings, _mock_app):
137
+ def test_warns_when_app_missing_even_if_pat_set(self, mock_settings, _mock_app):
137
138
  mock_settings.github_token = "ghp_test"
138
139
  with patch("app.services.github_app_token.logger") as mock_logger:
139
140
  warn_if_pat_used_for_write("Issue")
140
141
  mock_logger.warning.assert_called_once()
141
142
  message = mock_logger.warning.call_args[0][0]
142
- assert "GITHUB_TOKEN owner" in message
143
+ assert "not allowed" in message or "PAT" in message
143
144
 
144
145
  @patch("app.services.github_app_token.is_app_configured", return_value=False)
145
146
  @patch("app.services.github_app_token.settings")
@@ -149,4 +150,4 @@ class TestWarnIfPatUsedForWrite:
149
150
  warn_if_pat_used_for_write("Pull request")
150
151
  mock_logger.warning.assert_called_once()
151
152
  message = mock_logger.warning.call_args[0][0]
152
- assert "may fail" in message
153
+ assert "mock mode" in message or "not allowed" in message
@@ -10,6 +10,32 @@ from app.services.github_issues import create_issue
10
10
  from app.services.github_pr import _paginate_pulls, compare_branches
11
11
 
12
12
 
13
+ @pytest.fixture(autouse=True)
14
+ def _mock_github_app_token():
15
+ """PAT is forbidden — unit tests use GitHub App installation token mocks."""
16
+ with (
17
+ patch(
18
+ "app.services.github_app_token.is_app_configured",
19
+ return_value=True,
20
+ ),
21
+ patch(
22
+ "app.services.github_app_token.get_installation_token",
23
+ new_callable=AsyncMock,
24
+ return_value="ghs_test",
25
+ ),
26
+ patch(
27
+ "app.services.github_pr.is_app_configured",
28
+ return_value=True,
29
+ ),
30
+ patch(
31
+ "app.services.github_pr.get_installation_token",
32
+ new_callable=AsyncMock,
33
+ return_value="ghs_test",
34
+ ),
35
+ ):
36
+ yield
37
+
38
+
13
39
  class TestIsBinary:
14
40
  def test_xlsx_is_binary(self):
15
41
  assert _is_binary("docs/data.xlsx") is True
@@ -217,17 +243,17 @@ class TestPaginatePullsDedup:
217
243
  assert [pr["id"] for pr in result] == [10, 20]
218
244
 
219
245
 
220
- class TestCreateIssuePatWarning:
246
+ class TestCreateIssueAppWarning:
221
247
  @pytest.fixture
222
248
  def github_config(self):
223
249
  return {
224
250
  "owner": "testorg",
225
251
  "repo": "testrepo",
226
252
  "branch": "main",
227
- "token": "ghp_test",
253
+ "token": "",
228
254
  }
229
255
 
230
- async def test_create_issue_warns_when_pat_fallback(self, github_config):
256
+ async def test_create_issue_warns_when_app_missing(self, github_config):
231
257
  mock_resp = MagicMock()
232
258
  mock_resp.raise_for_status = MagicMock()
233
259
  mock_resp.json.return_value = {
@@ -249,7 +275,7 @@ class TestCreateIssuePatWarning:
249
275
  patch(
250
276
  "app.services.github_issues.resolve_token",
251
277
  new_callable=AsyncMock,
252
- return_value="ghp_test",
278
+ return_value="ghs_test",
253
279
  ),
254
280
  patch("app.services.github_issues.warn_if_pat_used_for_write") as mock_warn,
255
281
  ):
@@ -23,7 +23,7 @@ from app.services.pr_chain import (
23
23
  )
24
24
 
25
25
  FAKE_CONFIG = {
26
- "token": "ghp_test",
26
+ "token": "",
27
27
  "owner": "test-owner",
28
28
  "repo": "test-repo",
29
29
  "branch": "main",
@@ -39,6 +39,23 @@ FAKE_PR = {
39
39
  }
40
40
 
41
41
 
42
+ @pytest.fixture(autouse=True)
43
+ def _mock_github_app_token():
44
+ """PAT is forbidden — unit tests use GitHub App installation token mocks."""
45
+ with (
46
+ patch(
47
+ "app.services.github_pr.is_app_configured",
48
+ return_value=True,
49
+ ),
50
+ patch(
51
+ "app.services.github_pr.get_installation_token",
52
+ new_callable=AsyncMock,
53
+ return_value="ghs_test",
54
+ ),
55
+ ):
56
+ yield
57
+
58
+
42
59
  class TestFindPrByHead:
43
60
  @pytest.mark.asyncio
44
61
  async def test_returns_pr_when_found(self):
@@ -530,7 +547,7 @@ class TestCreatePrDraftFlag:
530
547
  assert call_json["draft"] is True
531
548
 
532
549
  @pytest.mark.asyncio
533
- async def test_create_pr_warns_when_pat_fallback(self):
550
+ async def test_create_pr_warns_when_app_missing(self):
534
551
  mock_resp = MagicMock()
535
552
  mock_resp.status_code = 201
536
553
  mock_resp.json.return_value = {**FAKE_PR, "draft": True}
@@ -542,7 +559,13 @@ class TestCreatePrDraftFlag:
542
559
  with (
543
560
  patch("app.services.github_pr.get_github_client", return_value=mock_client),
544
561
  patch("app.services.github_pr.is_app_configured", return_value=False),
562
+ patch(
563
+ "app.services.github_pr.get_installation_token",
564
+ new_callable=AsyncMock,
565
+ return_value="ghs_test",
566
+ ),
545
567
  patch("app.services.github_pr.warn_if_pat_used_for_write") as mock_warn,
568
+ pytest.raises(ValueError, match="GitHub App credentials required"),
546
569
  ):
547
570
  await create_pr("title", "head", "main", "body", [], FAKE_CONFIG)
548
571
 
@@ -16,7 +16,7 @@ class TestTree:
16
16
  self._headers = auth_headers(viewer_token)
17
17
 
18
18
  async def test_get_tree_mock_mode(self, client: AsyncClient):
19
- """Without GITHUB_TOKEN, tree returns mock data with source field."""
19
+ """Without GitHub App credentials, tree returns mock data with source field."""
20
20
  resp = await client.get("/api/tree", headers=self._headers)
21
21
  assert resp.status_code == 200
22
22
  data = resp.json()
@@ -9,12 +9,18 @@ export class LoginPage {
9
9
  readonly passwordInput: Locator;
10
10
  readonly submitButton: Locator;
11
11
  readonly errorAlert: Locator;
12
+ /** パスワード表示/非表示トグル(PasswordInput)。aria-label は表示状態で変わるため両方に一致させる。 */
13
+ readonly passwordToggle: Locator;
14
+ /** トグル状態を通知する sr-only ライブリージョン(PasswordInput)。 */
15
+ readonly passwordStatus: Locator;
12
16
 
13
17
  constructor(private readonly page: Page) {
14
18
  this.usernameInput = page.locator("#login-username");
15
19
  this.passwordInput = page.locator("#login-password");
16
20
  this.submitButton = page.locator('button[type="submit"]');
17
21
  this.errorAlert = page.locator('[role="alert"]');
22
+ this.passwordToggle = page.getByRole("button", { name: /^パスワードを(非表示|表示)$/ });
23
+ this.passwordStatus = page.getByRole("status");
18
24
  }
19
25
 
20
26
  async goto(): Promise<void> {
@@ -0,0 +1,198 @@
1
+ import { test, expect, TEST_USERNAME, TEST_PASSWORD } from "./fixtures";
2
+
3
+ /**
4
+ * REQ-UI-AC-13 / REQ-UI-AC-14 / REQ-UI-AC-17(#1140): ログイン画面のパスワード表示トグル
5
+ * (`PasswordInput`)を実ブラウザ(Playwright)で検証する。
6
+ *
7
+ * 既存の `src/components/PasswordInput.test.tsx` は jsdom のため **className トークン一致**までしか
8
+ * 検証できない(`pe-10` が付いていること/`focus-visible:ring-2` が付いていること)。以下は jsdom では
9
+ * 原理的に検証不可能で、本 spec でのみガードできる:
10
+ *
11
+ * - `pe-10` が実際にアイコンぶんの領域を確保し、トグルボタンが入力欄のテキスト領域に**重ならない**こと
12
+ * - 絶対配置(`absolute inset-y-0 end-0`)のトグルボタンが入力欄への**クリックを奪わない**こと(ヒットテスト)
13
+ * - `outline-none` + `focus-visible:ring-2` が実際にリングを描画し、マウス操作では描画しないこと
14
+ * - `type="button"` が実ブラウザで本当に submit を誘発しないこと(属性ではなく挙動として)
15
+ *
16
+ * ログイン画面は未認証状態が前提のため、`login.spec.ts` と同じく storageState を空にする。
17
+ */
18
+
19
+ // 幾何比較の許容差(px)。サブピクセル丸めを吸収する小さな値。
20
+ const GEOMETRY_TOLERANCE_PX = 1;
21
+
22
+ test.describe("パスワード表示トグル (REQ-UI-AC-13/14/17 / #1140)", () => {
23
+ test.use({ storageState: { cookies: [], origins: [] } });
24
+ // トグルの絶対配置は viewport 幅に依存しないが、幾何比較を安定させるため明示する。
25
+ test.use({ viewport: { width: 1280, height: 720 } });
26
+
27
+ test("初期状態では伏字(type=password)で描画される", async ({ loginPage }) => {
28
+ await loginPage.goto();
29
+
30
+ await expect(loginPage.passwordInput).toHaveAttribute("type", "password");
31
+ await expect(loginPage.passwordToggle).toHaveAccessibleName("パスワードを表示");
32
+ // 初回描画では何も起きていないため通知しない。
33
+ await expect(loginPage.passwordStatus).toHaveText("");
34
+ });
35
+
36
+ test("トグルで平文表示に切り替わり、再クリックで伏字に戻る", async ({ loginPage }) => {
37
+ await loginPage.goto();
38
+ await loginPage.passwordInput.fill(TEST_PASSWORD);
39
+
40
+ await loginPage.passwordToggle.click();
41
+ await expect(loginPage.passwordInput).toHaveAttribute("type", "text");
42
+ await expect(loginPage.passwordToggle).toHaveAccessibleName("パスワードを非表示");
43
+ await expect(loginPage.passwordStatus).toHaveText("パスワードを表示しました");
44
+ // 表示切替で値が失われないこと。
45
+ await expect(loginPage.passwordInput).toHaveValue(TEST_PASSWORD);
46
+
47
+ await loginPage.passwordToggle.click();
48
+ await expect(loginPage.passwordInput).toHaveAttribute("type", "password");
49
+ await expect(loginPage.passwordToggle).toHaveAccessibleName("パスワードを表示");
50
+ await expect(loginPage.passwordStatus).toHaveText("パスワードを非表示にしました");
51
+ await expect(loginPage.passwordInput).toHaveValue(TEST_PASSWORD);
52
+ });
53
+
54
+ test("トグルボタンはフォームを送信しない(type=button の実挙動)", async ({ page, loginPage }) => {
55
+ await loginPage.goto();
56
+ // 有効な認証情報を入れておく。誤って submit されれば遷移してしまうため、
57
+ // 「/login に留まる」ことが type=button の強い証拠になる。
58
+ await loginPage.usernameInput.fill(TEST_USERNAME);
59
+ await loginPage.passwordInput.fill(TEST_PASSWORD);
60
+
61
+ await page.evaluate(() => {
62
+ document.querySelector("form")?.addEventListener("submit", () => {
63
+ (window as unknown as { __submitted?: boolean }).__submitted = true;
64
+ });
65
+ });
66
+
67
+ await loginPage.passwordToggle.click();
68
+ await expect(loginPage.passwordInput).toHaveAttribute("type", "text");
69
+
70
+ const submitted = await page.evaluate(
71
+ () => (window as unknown as { __submitted?: boolean }).__submitted === true,
72
+ );
73
+ expect(submitted).toBe(false);
74
+ await expect(page).toHaveURL(/\/login/);
75
+ await expect(loginPage.errorAlert).toHaveCount(0);
76
+ });
77
+
78
+ test("pe-10 がアイコン領域を確保し、トグルが入力テキスト領域に重ならない", async ({
79
+ loginPage,
80
+ }) => {
81
+ await loginPage.goto();
82
+
83
+ // 入力欄のコンテンツボックス右端(border + padding-inline-end を除いた、文字が描画され得る右端)。
84
+ const contentRight = await loginPage.passwordInput.evaluate((el) => {
85
+ const r = el.getBoundingClientRect();
86
+ const cs = getComputedStyle(el);
87
+ return (
88
+ r.right - Number.parseFloat(cs.paddingInlineEnd) - Number.parseFloat(cs.borderRightWidth)
89
+ );
90
+ });
91
+
92
+ const toggleBox = await loginPage.passwordToggle.boundingBox();
93
+ if (!toggleBox) throw new Error("トグルボタンの boundingBox を取得できない");
94
+
95
+ // padding が実効していれば、ボタンの左端はコンテンツボックス右端以降にある。
96
+ // `pe-10` が失われるとボタンが文字領域に食い込み、この比較が red になる。
97
+ expect(toggleBox.x).toBeGreaterThanOrEqual(contentRight - GEOMETRY_TOLERANCE_PX);
98
+ });
99
+
100
+ test("トグルボタンは入力欄へのクリックを奪わない(ヒットテスト)", async ({
101
+ page,
102
+ loginPage,
103
+ }) => {
104
+ await loginPage.goto();
105
+
106
+ const inputBox = await loginPage.passwordInput.boundingBox();
107
+ if (!inputBox) throw new Error("パスワード入力欄の boundingBox を取得できない");
108
+
109
+ // 入力欄のテキスト領域(左寄り)をクリックすると入力欄がフォーカスされる。
110
+ await page.mouse.click(inputBox.x + 20, inputBox.y + inputBox.height / 2);
111
+ await expect(loginPage.passwordInput).toBeFocused();
112
+ // 入力欄が押されただけで表示状態は変わらない。
113
+ await expect(loginPage.passwordInput).toHaveAttribute("type", "password");
114
+
115
+ // 逆にアイコン位置のクリックは確実にボタンへ届く。
116
+ const toggleBox = await loginPage.passwordToggle.boundingBox();
117
+ if (!toggleBox) throw new Error("トグルボタンの boundingBox を取得できない");
118
+ await page.mouse.click(
119
+ toggleBox.x + toggleBox.width / 2,
120
+ toggleBox.y + toggleBox.height / 2,
121
+ );
122
+ await expect(loginPage.passwordInput).toHaveAttribute("type", "text");
123
+ });
124
+
125
+ test("キーボードフォーカス時のみ focus-visible リングを描画する", async ({
126
+ page,
127
+ loginPage,
128
+ }) => {
129
+ await loginPage.goto();
130
+
131
+ // マウスクリックでは :focus-visible にならず、リングも描画されない(outline-none のまま)。
132
+ await loginPage.passwordToggle.click();
133
+ const shadowAfterMouse = await loginPage.passwordToggle.evaluate(
134
+ (el) => getComputedStyle(el).boxShadow,
135
+ );
136
+ expect(shadowAfterMouse).toBe("none");
137
+
138
+ // パスワード入力欄から Tab するとトグルへキーボードフォーカスが移り、リングが描画される。
139
+ await loginPage.passwordInput.focus();
140
+ await page.keyboard.press("Tab");
141
+ await expect(loginPage.passwordToggle).toBeFocused();
142
+
143
+ const shadowAfterKeyboard = await loginPage.passwordToggle.evaluate(
144
+ (el) => getComputedStyle(el).boxShadow,
145
+ );
146
+ // `outline-none` で既定リングを消しているため、代替リングの実描画が必須(WCAG 2.4.11 / REQ-UI-AC-11)。
147
+ expect(shadowAfterKeyboard).not.toBe("none");
148
+ // ring-inset。外向きリングだと入力欄の丸角(rounded-lg)から角張って食み出す。
149
+ expect(shadowAfterKeyboard).toContain("inset");
150
+ });
151
+
152
+ // REQ-UI-AC-12 と同じ方針: jsdom は論理プロパティの実反転(物理側の特定)を検証できないため、
153
+ // `pe-10` / `end-0` が RTL で正しく反転することは E2E でのみガードできる(#1043)。
154
+ test("RTL ではトグルと入力欄パディングが揃って inline-end 側へ反転する", async ({
155
+ page,
156
+ loginPage,
157
+ }) => {
158
+ await loginPage.goto();
159
+
160
+ const measure = () =>
161
+ loginPage.passwordInput.evaluate((el) => {
162
+ const cs = getComputedStyle(el);
163
+ return {
164
+ left: el.getBoundingClientRect().left,
165
+ right: el.getBoundingClientRect().right,
166
+ paddingLeft: Number.parseFloat(cs.paddingLeft),
167
+ paddingRight: Number.parseFloat(cs.paddingRight),
168
+ };
169
+ });
170
+
171
+ const ltrInput = await measure();
172
+ const ltrToggle = await loginPage.passwordToggle.boundingBox();
173
+ if (!ltrToggle) throw new Error("トグルボタンの boundingBox を取得できない");
174
+
175
+ // LTR: アイコンは物理右、40px のパディングも物理右。
176
+ expect(ltrToggle.x).toBeGreaterThan(ltrInput.left + ltrInput.right - ltrInput.left - 60);
177
+ expect(ltrInput.paddingRight).toBeGreaterThan(ltrInput.paddingLeft);
178
+
179
+ await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl"));
180
+
181
+ const rtlInput = await measure();
182
+ const rtlToggle = await loginPage.passwordToggle.boundingBox();
183
+ if (!rtlToggle) throw new Error("RTL 時のトグル boundingBox を取得できない");
184
+
185
+ // 入力欄そのものの位置は変わらない(レイアウトが崩れていないことの確認)。
186
+ expect(rtlInput.left).toBeCloseTo(ltrInput.left, 0);
187
+ expect(rtlInput.right).toBeCloseTo(ltrInput.right, 0);
188
+
189
+ // RTL: アイコンは物理左へ移り、40px のパディングも物理左へ揃って移動する。
190
+ // 物理プロパティ(right-0 / pr-10)に戻すと、この 2 つが逆側に分かれて red になる。
191
+ expect(rtlToggle.x).toBeLessThan(ltrToggle.x);
192
+ expect(rtlToggle.x).toBeCloseTo(rtlInput.left, 0);
193
+ expect(rtlInput.paddingLeft).toBeGreaterThan(rtlInput.paddingRight);
194
+ expect(rtlInput.paddingLeft).toBeCloseTo(ltrInput.paddingRight, 0);
195
+
196
+ await page.evaluate(() => document.documentElement.removeAttribute("dir"));
197
+ });
198
+ });