@ripla/godd-mcp 1.0.4-canary.9 → 1.0.4
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.
- package/README.md +89 -94
- package/dist/godd.cjs +349 -233
- package/dist/godd.js +2193 -838
- package/dist/index.js +152 -122
- package/{notes-api → notes/api}/.env.example +6 -1
- package/{notes-api → notes/api}/README.md +6 -5
- package/{notes-api → notes/api}/app/main.py +29 -2
- package/{notes-api → notes/api}/app/routers/auth.py +6 -1
- package/{notes-api → notes/api}/app/routers/issues.py +157 -1
- package/{notes-api → notes/api}/app/routers/pr.py +2 -2
- package/{notes-api → notes/api}/app/services/github_app_token.py +32 -1
- package/{notes-api → notes/api}/app/services/github_issues.py +101 -11
- package/{notes-api → notes/api}/app/services/github_pr.py +6 -1
- package/{notes-api → notes/api}/tests/test_github_app_token.py +49 -1
- package/{notes-api → notes/api}/tests/test_github_service.py +42 -0
- package/notes/api/tests/test_health.py +52 -0
- package/{notes-api → notes/api}/tests/test_issues.py +216 -0
- package/{notes-api → notes/api}/tests/test_pr.py +24 -0
- package/{notes-api → notes/api}/tests/test_pr_chain.py +19 -0
- package/{notes-api → notes/api}/uv.lock +178 -2
- package/notes/app/DESIGN.md +273 -0
- package/{notes-app → notes/app}/README.md +4 -1
- package/notes/app/docker/Dockerfile.e2e +19 -0
- package/notes/app/docker/Dockerfile.prod +15 -0
- package/notes/app/docker/Dockerfile.test +17 -0
- package/notes/app/e2e/file-tree.spec.ts +26 -0
- package/notes/app/e2e/file-view.spec.ts +25 -0
- package/notes/app/e2e/fixtures.ts +28 -0
- package/{notes-app → notes/app}/e2e/global-setup.ts +5 -7
- package/notes/app/e2e/login.spec.ts +25 -0
- package/notes/app/e2e/pages/login-page.ts +29 -0
- package/notes/app/e2e/pages/main-page.ts +29 -0
- package/notes/app/e2e/panel-resize-handle.spec.ts +213 -0
- package/{notes-app → notes/app}/eslint.config.js +9 -0
- package/{notes-app → notes/app}/package.json +0 -3
- package/{notes-app → notes/app}/pnpm-workspace.yaml +1 -0
- package/notes/app/src/components/CommentPanel.test.tsx +194 -0
- package/{notes-app → notes/app}/src/components/CommentPanel.tsx +119 -10
- package/notes/app/src/components/CommentSelectionPopover.tsx +26 -0
- package/notes/app/src/components/EditToolbar.test.tsx +102 -0
- package/{notes-app → notes/app}/src/components/EditToolbar.tsx +5 -54
- package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +250 -0
- package/notes/app/src/components/FileInfoPanel.history-tab.test.tsx +146 -0
- package/notes/app/src/components/FileInfoPanel.test.tsx +101 -0
- package/{notes-app → notes/app}/src/components/FileInfoPanel.tsx +135 -20
- package/notes/app/src/components/IssueCommentThread.test.tsx +93 -0
- package/notes/app/src/components/IssueCommentThread.tsx +156 -0
- package/notes/app/src/components/IssueDetailView.test.tsx +1118 -0
- package/notes/app/src/components/IssueDetailView.tsx +476 -0
- package/notes/app/src/components/IssueList.test.tsx +545 -0
- package/notes/app/src/components/IssueList.tsx +374 -0
- package/{notes-app → notes/app}/src/components/LabelSelector.test.tsx +94 -8
- package/notes/app/src/components/LabelSelector.tsx +452 -0
- package/{notes-app → notes/app}/src/components/MarkdownEditor.tsx +140 -2
- package/notes/app/src/components/PanelResizeHandle.test.tsx +481 -0
- package/notes/app/src/components/PanelResizeHandle.tsx +96 -0
- package/{notes-app → notes/app}/src/hooks/useEditSession.test.ts +171 -23
- package/{notes-app → notes/app}/src/hooks/useEditSession.ts +49 -62
- package/notes/app/src/hooks/useMdUpLayout.test.ts +10 -0
- package/notes/app/src/hooks/useMdUpLayout.ts +27 -0
- package/notes/app/src/hooks/usePeriodicSync.test.ts +244 -0
- package/notes/app/src/hooks/usePeriodicSync.ts +83 -0
- package/notes/app/src/hooks/useResizablePanelWidth.test.ts +251 -0
- package/notes/app/src/hooks/useResizablePanelWidth.ts +167 -0
- package/{notes-app → notes/app}/src/lib/api.test.ts +81 -1
- package/{notes-app → notes/app}/src/lib/api.ts +98 -4
- package/notes/app/src/lib/commentAnchor.test.ts +22 -0
- package/notes/app/src/lib/commentAnchor.ts +28 -0
- package/notes/app/src/lib/commentHighlight.test.ts +110 -0
- package/notes/app/src/lib/commentHighlight.ts +44 -0
- package/notes/app/src/lib/github-sync.test.ts +126 -0
- package/notes/app/src/lib/github-sync.ts +89 -0
- package/notes/app/src/lib/issue-list-utils.test.ts +275 -0
- package/notes/app/src/lib/issue-list-utils.ts +135 -0
- package/notes/app/src/lib/issue-mobile-layout.test.ts +17 -0
- package/notes/app/src/lib/issue-mobile-layout.ts +20 -0
- package/notes/app/src/lib/issue-url-sync.test.ts +73 -0
- package/notes/app/src/lib/issue-url-sync.ts +43 -0
- package/notes/app/src/lib/main-page-default-view.test.ts +37 -0
- package/notes/app/src/lib/main-page-default-view.ts +15 -0
- package/notes/app/src/lib/resizable-panel.test.ts +199 -0
- package/notes/app/src/lib/resizable-panel.ts +136 -0
- package/notes/app/src/pages/LoginPage.test.tsx +17 -0
- package/notes/app/src/pages/MainPage.branch-modal.test.tsx +226 -0
- package/notes/app/src/pages/MainPage.issue-sync.test.tsx +145 -0
- package/notes/app/src/pages/MainPage.sidebar.test.tsx +361 -0
- package/{notes-app → notes/app}/src/pages/MainPage.tsx +406 -61
- package/{notes-app → notes/app}/src/pages/SettingsPage.test.tsx +32 -23
- package/{notes-app → notes/app}/src/pages/UserManagementPage.test.tsx +8 -18
- package/notes/app/src/test/mocks.ts +156 -0
- package/notes/app/src/test/render.tsx +27 -0
- package/package.json +3 -5
- package/notes-api/tests/test_health.py +0 -11
- package/notes-app/docker/Dockerfile.e2e +0 -14
- package/notes-app/docker/Dockerfile.prod +0 -12
- package/notes-app/docker/Dockerfile.test +0 -12
- package/notes-app/e2e/file-tree.spec.ts +0 -29
- package/notes-app/e2e/file-view.spec.ts +0 -24
- package/notes-app/e2e/login.spec.ts +0 -32
- package/notes-app/src/components/IssueDetailView.test.tsx +0 -201
- package/notes-app/src/components/IssueDetailView.tsx +0 -318
- package/notes-app/src/components/IssueList.test.tsx +0 -93
- package/notes-app/src/components/IssueList.tsx +0 -169
- package/notes-app/src/components/LabelSelector.tsx +0 -276
- package/notes-app/src/pages/LoginPage.test.tsx +0 -29
- package/stacks/django-vue.yaml +0 -33
- package/stacks/fastapi-react.yaml +0 -160
- package/stacks/flutter-firebase.yaml +0 -58
- package/stacks/nextjs-prisma.yaml +0 -33
- package/stacks/schema.json +0 -144
- package/templates/agents/architect.hbs +0 -183
- package/templates/agents/auto-documenter.hbs +0 -36
- package/templates/agents/backend-developer.hbs +0 -175
- package/templates/agents/code-reviewer.hbs +0 -29
- package/templates/agents/database-developer.hbs +0 -52
- package/templates/agents/debug-specialist.hbs +0 -39
- package/templates/agents/environment-setup.hbs +0 -41
- package/templates/agents/frontend-developer.hbs +0 -45
- package/templates/agents/integration-qa.hbs +0 -26
- package/templates/agents/performance-qa.hbs +0 -24
- package/templates/agents/pr-writer.hbs +0 -53
- package/templates/agents/quality-lead.hbs +0 -26
- package/templates/agents/requirements-analyst.hbs +0 -26
- package/templates/agents/security-analyst.hbs +0 -21
- package/templates/agents/security-reviewer.hbs +0 -20
- package/templates/agents/ssot-updater.hbs +0 -45
- package/templates/agents/technical-writer.hbs +0 -28
- package/templates/agents/unit-test-engineer.hbs +0 -19
- package/templates/agents/user-clone.hbs +0 -49
- package/templates/github-actions/aws/deploy-notes.yml.hbs +0 -117
- package/templates/github-actions/azure/deploy-notes.yml.hbs +0 -78
- package/templates/github-actions/gcp/deploy-notes.yml.hbs +0 -76
- package/templates/github-actions/vercel-railway/deploy-notes.yml.hbs +0 -49
- package/templates/mindsets/agent-stdio.hbs +0 -51
- package/templates/mindsets/dart.hbs +0 -39
- package/templates/mindsets/ddd-clean-architecture.hbs +0 -52
- package/templates/mindsets/electron.hbs +0 -41
- package/templates/mindsets/fastapi.hbs +0 -40
- package/templates/mindsets/flutter.hbs +0 -56
- package/templates/mindsets/kotlin.hbs +0 -46
- package/templates/mindsets/postgresql.hbs +0 -39
- package/templates/mindsets/python.hbs +0 -39
- package/templates/mindsets/react.hbs +0 -51
- package/templates/mindsets/swift.hbs +0 -46
- package/templates/mindsets/terraform.hbs +0 -53
- package/templates/mindsets/typescript.hbs +0 -34
- package/templates/mindsets/vite.hbs +0 -37
- package/templates/notes-compose.yml +0 -82
- package/templates/partials/cogito-geo.hbs +0 -84
- package/templates/partials/cogito-godd.hbs +0 -22
- package/templates/partials/cogito-grammar.hbs +0 -105
- package/templates/partials/cogito-particle.hbs +0 -76
- package/templates/partials/cogito-root-ai.hbs +0 -79
- package/templates/partials/cogito-root-arch.hbs +0 -75
- package/templates/partials/cogito-root-core.hbs +0 -75
- package/templates/partials/cogito-root-daily.hbs +0 -58
- package/templates/partials/cogito-root-dev.hbs +0 -71
- package/templates/partials/cogito-root-devops.hbs +0 -71
- package/templates/partials/cogito-root-framework.hbs +0 -80
- package/templates/partials/cogito-root-lang.hbs +0 -74
- package/templates/partials/godd-core.hbs +0 -50
- package/templates/partials/project-context.hbs +0 -52
- package/templates/partials/ssot-header.hbs +0 -40
- package/templates/partials/ssot-rules.hbs +0 -30
- package/templates/prompts/adr.hbs +0 -39
- package/templates/prompts/agent-dev-workflow.hbs +0 -34
- package/templates/prompts/analyze-report.hbs +0 -36
- package/templates/prompts/auto-documentation.hbs +0 -37
- package/templates/prompts/check.hbs +0 -52
- package/templates/prompts/commit.hbs +0 -96
- package/templates/prompts/dev.hbs +0 -127
- package/templates/prompts/docs-init.hbs +0 -101
- package/templates/prompts/docs-update.hbs +0 -51
- package/templates/prompts/git-workflow.hbs +0 -125
- package/templates/prompts/impact-analysis.hbs +0 -43
- package/templates/prompts/install.hbs +0 -40
- package/templates/prompts/new-branch.hbs +0 -61
- package/templates/prompts/notes-deploy.hbs +0 -138
- package/templates/prompts/pr-analyze.hbs +0 -44
- package/templates/prompts/pr-create.hbs +0 -125
- package/templates/prompts/push-execute.hbs +0 -83
- package/templates/prompts/push.hbs +0 -24
- package/templates/prompts/release-notes.hbs +0 -38
- package/templates/prompts/requirements-to-business-flow.hbs +0 -29
- package/templates/prompts/requirements-to-tickets.hbs +0 -26
- package/templates/prompts/review.hbs +0 -126
- package/templates/prompts/setup.hbs +0 -247
- package/templates/prompts/spec.hbs +0 -35
- package/templates/prompts/specification-to-tickets.hbs +0 -24
- package/templates/prompts/submit.hbs +0 -143
- package/templates/prompts/sync.hbs +0 -63
- package/templates/prompts/test-plan.hbs +0 -56
- package/templates/scripts/aws/terraform-backend-setup.sh.hbs +0 -149
- package/templates/terraform/aws/alb.tf.hbs +0 -58
- package/templates/terraform/aws/backend.tf.hbs +0 -10
- package/templates/terraform/aws/cloudfront.tf.hbs +0 -96
- package/templates/terraform/aws/ecr.tf.hbs +0 -30
- package/templates/terraform/aws/ecs.tf.hbs +0 -142
- package/templates/terraform/aws/iam.tf.hbs +0 -88
- package/templates/terraform/aws/local.tf.hbs +0 -34
- package/templates/terraform/aws/output.tf.hbs +0 -49
- package/templates/terraform/aws/provider.tf.hbs +0 -26
- package/templates/terraform/aws/rds.tf.hbs +0 -38
- package/templates/terraform/aws/s3.tf.hbs +0 -34
- package/templates/terraform/aws/secrets.tf.hbs +0 -53
- package/templates/terraform/aws/security_groups.tf.hbs +0 -81
- package/templates/terraform/aws/vpc.tf.hbs +0 -63
- package/templates/terraform/azure/backend.tf.hbs +0 -8
- package/templates/terraform/azure/iam.tf.hbs +0 -36
- package/templates/terraform/azure/main.tf.hbs +0 -203
- package/templates/terraform/azure/output.tf.hbs +0 -19
- package/templates/terraform/azure/provider.tf.hbs +0 -19
- package/templates/terraform/gcp/backend.tf.hbs +0 -6
- package/templates/terraform/gcp/iam.tf.hbs +0 -47
- package/templates/terraform/gcp/main.tf.hbs +0 -175
- package/templates/terraform/gcp/output.tf.hbs +0 -15
- package/templates/terraform/gcp/provider.tf.hbs +0 -19
- package/templates/terraform/vercel-railway/main.tf.hbs +0 -64
- package/templates/terraform/vercel-railway/output.tf.hbs +0 -11
- /package/{notes-api → notes/api}/.dockerignore +0 -0
- /package/{notes-api → notes/api}/alembic/README +0 -0
- /package/{notes-api → notes/api}/alembic/env.py +0 -0
- /package/{notes-api → notes/api}/alembic/script.py.mako +0 -0
- /package/{notes-api → notes/api}/alembic/versions/001_initial_notes_tables.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/002_add_performance_indexes.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/003_add_yjs_documents.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/004_add_comments.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/005_add_github_username.py +0 -0
- /package/{notes-api → notes/api}/alembic/versions/006_add_check_constraints.py +0 -0
- /package/{notes-api → notes/api}/alembic.ini +0 -0
- /package/{notes-api → notes/api}/app/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/config.py +0 -0
- /package/{notes-api → notes/api}/app/database.py +0 -0
- /package/{notes-api → notes/api}/app/http_client.py +0 -0
- /package/{notes-api → notes/api}/app/models/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/models/audit.py +0 -0
- /package/{notes-api → notes/api}/app/models/base.py +0 -0
- /package/{notes-api → notes/api}/app/models/collab.py +0 -0
- /package/{notes-api → notes/api}/app/models/comment.py +0 -0
- /package/{notes-api → notes/api}/app/models/session.py +0 -0
- /package/{notes-api → notes/api}/app/models/setting.py +0 -0
- /package/{notes-api → notes/api}/app/models/user.py +0 -0
- /package/{notes-api → notes/api}/app/path_validation.py +0 -0
- /package/{notes-api → notes/api}/app/routers/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/routers/collab.py +0 -0
- /package/{notes-api → notes/api}/app/routers/comments.py +0 -0
- /package/{notes-api → notes/api}/app/routers/files.py +0 -0
- /package/{notes-api → notes/api}/app/routers/settings.py +0 -0
- /package/{notes-api → notes/api}/app/routers/tree.py +0 -0
- /package/{notes-api → notes/api}/app/routers/upload.py +0 -0
- /package/{notes-api → notes/api}/app/routers/users.py +0 -0
- /package/{notes-api → notes/api}/app/schemas/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/schemas/auth.py +0 -0
- /package/{notes-api → notes/api}/app/schemas/user.py +0 -0
- /package/{notes-api → notes/api}/app/security.py +0 -0
- /package/{notes-api → notes/api}/app/services/__init__.py +0 -0
- /package/{notes-api → notes/api}/app/services/audit.py +0 -0
- /package/{notes-api → notes/api}/app/services/business_date.py +0 -0
- /package/{notes-api → notes/api}/app/services/collab.py +0 -0
- /package/{notes-api → notes/api}/app/services/github.py +0 -0
- /package/{notes-api → notes/api}/app/services/pr_chain.py +0 -0
- /package/{notes-api → notes/api}/app/startup.py +0 -0
- /package/{notes-api → notes/api}/docker/Dockerfile +0 -0
- /package/{notes-api → notes/api}/docker/Dockerfile.test +0 -0
- /package/{notes-api → notes/api}/pyproject.toml +0 -0
- /package/{notes-api → notes/api}/tests/conftest.py +0 -0
- /package/{notes-api → notes/api}/tests/test_audit.py +0 -0
- /package/{notes-api → notes/api}/tests/test_auth.py +0 -0
- /package/{notes-api → notes/api}/tests/test_business_date.py +0 -0
- /package/{notes-api → notes/api}/tests/test_collab.py +0 -0
- /package/{notes-api → notes/api}/tests/test_comments.py +0 -0
- /package/{notes-api → notes/api}/tests/test_config_ssl.py +0 -0
- /package/{notes-api → notes/api}/tests/test_config_warnings.py +0 -0
- /package/{notes-api → notes/api}/tests/test_files.py +0 -0
- /package/{notes-api → notes/api}/tests/test_path_validation.py +0 -0
- /package/{notes-api → notes/api}/tests/test_security.py +0 -0
- /package/{notes-api → notes/api}/tests/test_settings.py +0 -0
- /package/{notes-api → notes/api}/tests/test_tree.py +0 -0
- /package/{notes-api → notes/api}/tests/test_upload.py +0 -0
- /package/{notes-api → notes/api}/tests/test_users.py +0 -0
- /package/{notes-app → notes/app}/.dockerignore +0 -0
- /package/{notes-app → notes/app}/index.html +0 -0
- /package/{notes-app → notes/app}/nginx.conf +0 -0
- /package/{notes-app → notes/app}/package-lock.json +0 -0
- /package/{notes-app → notes/app}/playwright.config.ts +0 -0
- /package/{notes-app → notes/app}/pnpm-lock.yaml +0 -0
- /package/{notes-app → notes/app}/src/App.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/BranchSelectorModal.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/CodeBlockView.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/CodeBlockView.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/CollabStatus.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/DiffPreview.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/DrawioEditor.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/DrawioViewer.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/ErrorBoundary.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/ErrorBoundary.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileContentView.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileContextMenu.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileContextMenu.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileDialogs.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/FileDialogs.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SaveStatusIndicator.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SaveStatusIndicator.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/Skeleton.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SpreadsheetEditor.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/SpreadsheetEditor.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/TextEditor.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/ToastContainer.tsx +0 -0
- /package/{notes-app → notes/app}/src/components/TreeNode.tsx +0 -0
- /package/{notes-app → notes/app}/src/contexts/AuthContext.test.tsx +0 -0
- /package/{notes-app → notes/app}/src/contexts/AuthContext.tsx +0 -0
- /package/{notes-app → notes/app}/src/contexts/ToastContext.test.ts +0 -0
- /package/{notes-app → notes/app}/src/contexts/ToastContext.tsx +0 -0
- /package/{notes-app → notes/app}/src/hooks/useAutoSave.test.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useAutoSave.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useCollaboration.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useComments.ts +0 -0
- /package/{notes-app → notes/app}/src/hooks/useYText.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/branch-diff.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/branch-diff.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/created-file.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/created-file.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/csv-utils.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/csv-utils.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/deepLinkPathToSearch.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/deepLinkPathToSearch.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/draft-storage.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/draft-storage.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/mermaid-render.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/postLoginRedirect.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/postLoginRedirect.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/tree-utils.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/user-colors.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/xlsx-utils.test.ts +0 -0
- /package/{notes-app → notes/app}/src/lib/xlsx-utils.ts +0 -0
- /package/{notes-app → notes/app}/src/main.tsx +0 -0
- /package/{notes-app → notes/app}/src/pages/LoginPage.tsx +0 -0
- /package/{notes-app → notes/app}/src/pages/MainPage.insertChildEntry.test.ts +0 -0
- /package/{notes-app → notes/app}/src/pages/SettingsPage.tsx +0 -0
- /package/{notes-app → notes/app}/src/pages/UserManagementPage.tsx +0 -0
- /package/{notes-app → notes/app}/src/styles/globals.css +0 -0
- /package/{notes-app → notes/app}/src/types.ts +0 -0
- /package/{notes-app → notes/app}/src/vite-env.d.ts +0 -0
- /package/{notes-app → notes/app}/tsconfig.json +0 -0
- /package/{notes-app → notes/app}/tsconfig.node.json +0 -0
- /package/{notes-app → notes/app}/vite.config.js +0 -0
- /package/{notes-app → notes/app}/vite.config.ts +0 -0
- /package/{notes-app → notes/app}/vitest.config.ts +0 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
|
3
|
+
import { mockAuthContext } from "@/test/mocks";
|
|
4
|
+
|
|
5
|
+
vi.mock("@/lib/api", () => ({
|
|
6
|
+
createIssueApi: vi.fn(),
|
|
7
|
+
updateIssueApi: vi.fn(),
|
|
8
|
+
getIssueApi: vi.fn(),
|
|
9
|
+
listIssueCommentsApi: vi.fn().mockResolvedValue({ comments: [] }),
|
|
10
|
+
getErrorMessage: vi.fn((e: unknown) => String(e)),
|
|
11
|
+
isAbortError: vi.fn(() => false),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@/contexts/AuthContext", () => ({
|
|
15
|
+
useAuth: () => mockAuthContext({ canEdit: true }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockShowToast = vi.hoisted(() => vi.fn());
|
|
19
|
+
vi.mock("@/contexts/ToastContext", () => ({
|
|
20
|
+
useToast: () => ({ showToast: mockShowToast }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const triggerPeriodicSync = vi.hoisted(() => vi.fn());
|
|
24
|
+
vi.mock("@/hooks/usePeriodicSync", () => {
|
|
25
|
+
// 実フック(usePeriodicSync.ts)の挙動を忠実に再現する(#1006 / FileInfoPanel.branch-tab.test.tsx と同方式)。
|
|
26
|
+
// onSync を ref 捕捉し、effect 依存は実フックと同じ [enabled, intervalMs, skipInitialSync]。
|
|
27
|
+
// IssueDetailView は skipInitialSync=true(#929)を渡すため、本番同様マウント時の即時 poll は抑制され、
|
|
28
|
+
// mount では foreground loadIssue のみが走る。これにより従来の mock が注入していた「phantom mount poll」
|
|
29
|
+
// (本番には存在しない 2 本目の in-flight fetch)が無くなり、triggered poll との競合(#980 / #1031 で
|
|
30
|
+
// setTimeout flush 緩和していた flaky の根本原因)が解消する。
|
|
31
|
+
// 非モデル化(意図的): 実フックの isSyncing(in-flight 中の tick スキップ)/ visibilitychange(非表示中停止)/
|
|
32
|
+
// setInterval は再現しない。poll は triggerPeriodicSync で明示発火するテストシームのため不要。これらの
|
|
33
|
+
// 挙動に依存するテストを足す場合は本 mock の拡張が必要(現行テストはいずれも未依存)。
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- mock factory 制約
|
|
35
|
+
const { useEffect, useRef } = require("react") as typeof import("react");
|
|
36
|
+
return {
|
|
37
|
+
usePeriodicSync: ({
|
|
38
|
+
onSync,
|
|
39
|
+
enabled,
|
|
40
|
+
intervalMs,
|
|
41
|
+
skipInitialSync,
|
|
42
|
+
}: {
|
|
43
|
+
onSync: () => void;
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
intervalMs: number;
|
|
46
|
+
skipInitialSync?: boolean;
|
|
47
|
+
}) => {
|
|
48
|
+
const onSyncRef = useRef(onSync);
|
|
49
|
+
const isInitialMountRef = useRef(true);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
onSyncRef.current = onSync;
|
|
52
|
+
}, [onSync]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
// trigger は常に最新 onSync を呼ぶ(mock 差し替え後の closed 応答を確実に拾う)。
|
|
55
|
+
triggerPeriodicSync.mockImplementation(() => {
|
|
56
|
+
if (enabled) onSyncRef.current();
|
|
57
|
+
});
|
|
58
|
+
const isInitialMount = isInitialMountRef.current;
|
|
59
|
+
isInitialMountRef.current = false;
|
|
60
|
+
if (!enabled || intervalMs <= 0) return;
|
|
61
|
+
if (skipInitialSync && isInitialMount) return;
|
|
62
|
+
onSyncRef.current();
|
|
63
|
+
}, [enabled, intervalMs, skipInitialSync]);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
vi.mock("@/components/FileContentView", () => ({
|
|
69
|
+
default: ({ content }: { content: string }) => (
|
|
70
|
+
<div data-testid="file-content-view">{content}</div>
|
|
71
|
+
),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
vi.mock("@/components/LabelSelector", () => ({
|
|
75
|
+
default: ({
|
|
76
|
+
selected,
|
|
77
|
+
onLabelsMutated,
|
|
78
|
+
}: {
|
|
79
|
+
selected?: string[];
|
|
80
|
+
onLabelsMutated?: () => void;
|
|
81
|
+
}) => (
|
|
82
|
+
<div>
|
|
83
|
+
<span data-testid="selected-labels">{(selected ?? []).join(",")}</span>
|
|
84
|
+
<button type="button" data-testid="trigger-label-mutate" onClick={() => onLabelsMutated?.()}>
|
|
85
|
+
labels
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
import { createIssueApi, updateIssueApi, getIssueApi } from "@/lib/api";
|
|
92
|
+
import IssueDetailView from "./IssueDetailView";
|
|
93
|
+
|
|
94
|
+
const mockCreateIssue = vi.mocked(createIssueApi);
|
|
95
|
+
const mockUpdateIssue = vi.mocked(updateIssueApi);
|
|
96
|
+
const mockGetIssue = vi.mocked(getIssueApi);
|
|
97
|
+
|
|
98
|
+
const MOCK_ISSUE = {
|
|
99
|
+
number: 123,
|
|
100
|
+
title: "テスト Issue",
|
|
101
|
+
body: "本文テスト",
|
|
102
|
+
state: "open" as const,
|
|
103
|
+
author: "testuser",
|
|
104
|
+
assignees: [],
|
|
105
|
+
comments: 0,
|
|
106
|
+
createdAt: "2026-05-19T00:00:00Z",
|
|
107
|
+
updatedAt: "2026-05-19T00:00:00Z",
|
|
108
|
+
url: "https://github.com/test/repo/issues/123",
|
|
109
|
+
labels: [],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
describe("IssueDetailView — 新規 Issue 作成", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
vi.clearAllMocks();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("作成フォームが表示される(isNew=true)", () => {
|
|
118
|
+
render(
|
|
119
|
+
<IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
120
|
+
);
|
|
121
|
+
expect(screen.getByText("新規 Issue 作成")).toBeTruthy();
|
|
122
|
+
expect(screen.getByPlaceholderText("Issue のタイトル")).toBeTruthy();
|
|
123
|
+
expect(screen.getByText("作成")).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("タイトル未入力時は作成ボタンが disabled", () => {
|
|
127
|
+
render(
|
|
128
|
+
<IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
129
|
+
);
|
|
130
|
+
const btn = screen.getByText("作成");
|
|
131
|
+
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("作成成功後にプレビュー画面へ遷移し、onCreated が呼ばれる", async () => {
|
|
135
|
+
mockCreateIssue.mockResolvedValue(MOCK_ISSUE);
|
|
136
|
+
|
|
137
|
+
const onCreated = vi.fn();
|
|
138
|
+
render(
|
|
139
|
+
<IssueDetailView issueNumber="new" onCreated={onCreated} onClose={vi.fn()} />,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const titleInput = screen.getByPlaceholderText("Issue のタイトル");
|
|
143
|
+
fireEvent.change(titleInput, { target: { value: "テスト Issue" } });
|
|
144
|
+
|
|
145
|
+
await act(async () => {
|
|
146
|
+
fireEvent.click(screen.getByText("作成"));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// プレビュー画面に遷移(編集フォームが消える)
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(screen.queryByText("新規 Issue 作成")).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Issue タイトルがプレビュー表示される
|
|
155
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
156
|
+
|
|
157
|
+
// onCreated コールバックが作成された Issue オブジェクトで呼ばれる
|
|
158
|
+
expect(onCreated).toHaveBeenCalledWith(MOCK_ISSUE);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("キャンセルボタンで onClose が呼ばれる", () => {
|
|
162
|
+
const onClose = vi.fn();
|
|
163
|
+
render(
|
|
164
|
+
<IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={onClose} />,
|
|
165
|
+
);
|
|
166
|
+
fireEvent.click(screen.getByText("キャンセル"));
|
|
167
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("IssueDetailView — 既存 Issue 更新", () => {
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
vi.clearAllMocks();
|
|
174
|
+
mockGetIssue.mockResolvedValue(MOCK_ISSUE);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("更新成功後にプレビュー画面へ遷移する(既存の更新と同じ挙動)", async () => {
|
|
178
|
+
const updatedIssue = { ...MOCK_ISSUE, title: "更新後タイトル" };
|
|
179
|
+
mockUpdateIssue.mockResolvedValue(updatedIssue);
|
|
180
|
+
|
|
181
|
+
render(
|
|
182
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await waitFor(() => {
|
|
186
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
fireEvent.click(screen.getByText("編集"));
|
|
190
|
+
|
|
191
|
+
const titleInput = screen.getByDisplayValue("テスト Issue");
|
|
192
|
+
fireEvent.change(titleInput, { target: { value: "更新後タイトル" } });
|
|
193
|
+
|
|
194
|
+
await act(async () => {
|
|
195
|
+
fireEvent.click(screen.getByText("保存"));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(screen.queryByText("保存")).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(screen.getByText("更新後タイトル")).toBeTruthy();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("モバイル向けスクロール可能な詳細ルートを使う", async () => {
|
|
206
|
+
render(
|
|
207
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await waitFor(() => {
|
|
211
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const editButton = screen.getByText("編集");
|
|
215
|
+
expect(editButton.className).toContain("min-h-11");
|
|
216
|
+
expect(editButton.className).toContain("touch-manipulation");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// #708 固有シナリオ
|
|
221
|
+
describe("IssueDetailView — #708 「新規 Issue」ボタンのリセット挙動", () => {
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
vi.clearAllMocks();
|
|
224
|
+
mockGetIssue.mockResolvedValue(MOCK_ISSUE);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("既存 Issue 編集・保存後に 'new' でリマウントすると空のフォームが表示される", async () => {
|
|
228
|
+
const updatedIssue = { ...MOCK_ISSUE, title: "更新後" };
|
|
229
|
+
mockUpdateIssue.mockResolvedValue(updatedIssue);
|
|
230
|
+
|
|
231
|
+
const { unmount } = render(
|
|
232
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
fireEvent.click(screen.getByText("編集"));
|
|
240
|
+
const titleInput = screen.getByDisplayValue("テスト Issue");
|
|
241
|
+
fireEvent.change(titleInput, { target: { value: "更新後" } });
|
|
242
|
+
await act(async () => {
|
|
243
|
+
fireEvent.click(screen.getByText("保存"));
|
|
244
|
+
});
|
|
245
|
+
await waitFor(() => {
|
|
246
|
+
expect(screen.queryByText("保存")).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// 親コンポーネントが key を変えることでリマウント(「新規 Issue」ボタン相当)
|
|
250
|
+
unmount();
|
|
251
|
+
render(
|
|
252
|
+
<IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(screen.getByText("新規 Issue 作成")).toBeTruthy();
|
|
256
|
+
const newTitleInput = screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement;
|
|
257
|
+
expect(newTitleInput.value).toBe("");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("新規 Issue フォーム入力途中にリマウントすると空のフォームにリセットされる", () => {
|
|
261
|
+
const { unmount } = render(
|
|
262
|
+
<IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const titleInput = screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement;
|
|
266
|
+
fireEvent.change(titleInput, { target: { value: "途中入力" } });
|
|
267
|
+
expect(titleInput.value).toBe("途中入力");
|
|
268
|
+
|
|
269
|
+
// 「新規 Issue」ボタン再クリック相当(newIssueCounter インクリメント → リマウント)
|
|
270
|
+
unmount();
|
|
271
|
+
render(
|
|
272
|
+
<IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const freshInput = screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement;
|
|
276
|
+
expect(freshInput.value).toBe("");
|
|
277
|
+
expect(screen.getByText("新規 Issue 作成")).toBeTruthy();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("IssueDetailView — GitHub 外部変更の同期", () => {
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
vi.clearAllMocks();
|
|
284
|
+
// vi.clearAllMocks() は呼び出し履歴のみ消去し mockResolvedValueOnce のキューは消さないため、
|
|
285
|
+
// 直前テストで消費し切らなかった once 実装が次テストへ漏れる(#1006)。getIssue は once 連鎖を
|
|
286
|
+
// 多用するので mockReset で once キュー・実装を明示クリアし、テスト間の汚染を断つ。
|
|
287
|
+
mockGetIssue.mockReset();
|
|
288
|
+
mockGetIssue.mockResolvedValue(MOCK_ISSUE);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("syncEnabled=true で mount 直後の即時 poll 後も loading が解除される", async () => {
|
|
292
|
+
mockGetIssue
|
|
293
|
+
.mockReset()
|
|
294
|
+
.mockResolvedValueOnce(MOCK_ISSUE)
|
|
295
|
+
.mockResolvedValueOnce(MOCK_ISSUE);
|
|
296
|
+
|
|
297
|
+
render(
|
|
298
|
+
<IssueDetailView
|
|
299
|
+
issueNumber={123}
|
|
300
|
+
onCreated={vi.fn()}
|
|
301
|
+
onClose={vi.fn()}
|
|
302
|
+
syncEnabled
|
|
303
|
+
/>,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
308
|
+
});
|
|
309
|
+
expect(screen.queryByText("Issue を読み込み中…")).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("ポーリングで state 変化を検知したら onExternalChange を呼ぶ", async () => {
|
|
313
|
+
const onExternalChange = vi.fn();
|
|
314
|
+
render(
|
|
315
|
+
<IssueDetailView
|
|
316
|
+
issueNumber={123}
|
|
317
|
+
onCreated={vi.fn()}
|
|
318
|
+
onClose={vi.fn()}
|
|
319
|
+
onExternalChange={onExternalChange}
|
|
320
|
+
syncEnabled
|
|
321
|
+
/>,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// #1006: usePeriodicSync の忠実モック(skipInitialSync=true)では mount で foreground loadIssue のみが
|
|
325
|
+
// 走り、本番に存在しない phantom mount poll が注入されない。これにより #980 / #1031 で問題だった
|
|
326
|
+
// 「2 本の in-flight fetch(mount poll + triggered poll)の競合」は解消する。
|
|
327
|
+
await waitFor(() => {
|
|
328
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// issueRef は issue state を追従する passive useEffect(IssueDetailView.tsx:93)で更新されるため、
|
|
332
|
+
// 描画完了(上の waitFor)後も effect 未実行の瞬間がある。その状態で poll すると pollIssue の
|
|
333
|
+
// previous=issueRef.current が null となり snapshotChanged=false で onExternalChange が呼ばれない。
|
|
334
|
+
// poll トリガ前に issueRef=open を確定させる。flush しているのは内側の microtask ではなく act() 境界で、
|
|
335
|
+
// React 19 + RTL は act 終了時に保留中の passive effect を flush する(CPU 競合下でも決定的)。
|
|
336
|
+
// この act ラッパを外すと flush されず flaky が再発するため、簡略化しないこと
|
|
337
|
+
// (本番では poll は interval 後に走るためこの競合は起きない=テスト固有のタイミング保証)。
|
|
338
|
+
await act(async () => {
|
|
339
|
+
await Promise.resolve();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
mockGetIssue.mockImplementation(async () => ({
|
|
343
|
+
...MOCK_ISSUE,
|
|
344
|
+
state: "closed",
|
|
345
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
await act(async () => {
|
|
349
|
+
triggerPeriodicSync();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await waitFor(() => {
|
|
353
|
+
expect(onExternalChange).toHaveBeenCalledWith(
|
|
354
|
+
expect.objectContaining({ state: "closed" }),
|
|
355
|
+
{ background: true },
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("poll 中に編集モードへ入るとフォーム入力が上書きされない", async () => {
|
|
361
|
+
let resolvePoll: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
362
|
+
mockGetIssue
|
|
363
|
+
.mockResolvedValueOnce(MOCK_ISSUE)
|
|
364
|
+
.mockImplementationOnce(
|
|
365
|
+
() =>
|
|
366
|
+
new Promise((resolve) => {
|
|
367
|
+
resolvePoll = resolve;
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
render(
|
|
372
|
+
<IssueDetailView
|
|
373
|
+
issueNumber={123}
|
|
374
|
+
onCreated={vi.fn()}
|
|
375
|
+
onClose={vi.fn()}
|
|
376
|
+
syncEnabled
|
|
377
|
+
/>,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
await waitFor(() => {
|
|
381
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await act(async () => {
|
|
385
|
+
triggerPeriodicSync();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
fireEvent.click(screen.getByText("編集"));
|
|
389
|
+
const titleInput = screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement;
|
|
390
|
+
fireEvent.change(titleInput, { target: { value: "編集中のタイトル" } });
|
|
391
|
+
|
|
392
|
+
await act(async () => {
|
|
393
|
+
resolvePoll?.({
|
|
394
|
+
...MOCK_ISSUE,
|
|
395
|
+
title: "GitHub 側の更新",
|
|
396
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
397
|
+
});
|
|
398
|
+
await Promise.resolve();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(titleInput.value).toBe("編集中のタイトル");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("Issue 切替後は in-flight poll の古い応答を適用しない", async () => {
|
|
405
|
+
let resolveOldPoll: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
406
|
+
// #1006: phantom mount poll が無くなったため getIssue は load(123) → triggered poll(123, in-flight)
|
|
407
|
+
// → load(456) の 3 回。2 番目を in-flight poll、3 番目を Issue 456 の load にする。
|
|
408
|
+
mockGetIssue
|
|
409
|
+
.mockResolvedValueOnce(MOCK_ISSUE)
|
|
410
|
+
.mockImplementationOnce(
|
|
411
|
+
() =>
|
|
412
|
+
new Promise((resolve) => {
|
|
413
|
+
resolveOldPoll = resolve;
|
|
414
|
+
}),
|
|
415
|
+
)
|
|
416
|
+
.mockResolvedValueOnce({
|
|
417
|
+
...MOCK_ISSUE,
|
|
418
|
+
number: 456,
|
|
419
|
+
title: "Issue 456",
|
|
420
|
+
// 切替先の別 issue は新しい updatedAt を持つ。123 の load が解決済み(issueRef=open)のため、
|
|
421
|
+
// 同一秒の後退ガード(#979)に掛からないよう新しい updatedAt にして確実に適用させる。
|
|
422
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const { rerender } = render(
|
|
426
|
+
<IssueDetailView
|
|
427
|
+
issueNumber={123}
|
|
428
|
+
onCreated={vi.fn()}
|
|
429
|
+
onClose={vi.fn()}
|
|
430
|
+
syncEnabled
|
|
431
|
+
/>,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await waitFor(() => {
|
|
435
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await act(async () => {
|
|
439
|
+
triggerPeriodicSync();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
rerender(
|
|
443
|
+
<IssueDetailView
|
|
444
|
+
issueNumber={456}
|
|
445
|
+
onCreated={vi.fn()}
|
|
446
|
+
onClose={vi.fn()}
|
|
447
|
+
syncEnabled
|
|
448
|
+
/>,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await waitFor(() => {
|
|
452
|
+
expect(screen.getByText("Issue 456")).toBeTruthy();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await act(async () => {
|
|
456
|
+
resolveOldPoll?.({
|
|
457
|
+
...MOCK_ISSUE,
|
|
458
|
+
title: "古い Issue 123 の応答",
|
|
459
|
+
});
|
|
460
|
+
await Promise.resolve();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(screen.getByText("Issue 456")).toBeTruthy();
|
|
464
|
+
expect(screen.queryByText("古い Issue 123 の応答")).toBeNull();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("Issue 切替後は in-flight load の古い応答を適用しない", async () => {
|
|
468
|
+
let resolveOldLoad: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
469
|
+
mockGetIssue
|
|
470
|
+
.mockImplementationOnce(
|
|
471
|
+
() =>
|
|
472
|
+
new Promise((resolve) => {
|
|
473
|
+
resolveOldLoad = resolve;
|
|
474
|
+
}),
|
|
475
|
+
)
|
|
476
|
+
.mockResolvedValueOnce({
|
|
477
|
+
...MOCK_ISSUE,
|
|
478
|
+
number: 456,
|
|
479
|
+
title: "Issue 456",
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const { rerender } = render(
|
|
483
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
rerender(
|
|
487
|
+
<IssueDetailView issueNumber={456} onCreated={vi.fn()} onClose={vi.fn()} />,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await waitFor(() => {
|
|
491
|
+
expect(screen.getByText("Issue 456")).toBeTruthy();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await act(async () => {
|
|
495
|
+
resolveOldLoad?.({
|
|
496
|
+
...MOCK_ISSUE,
|
|
497
|
+
title: "古い Issue 123 の load 応答",
|
|
498
|
+
});
|
|
499
|
+
await Promise.resolve();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(screen.getByText("Issue 456")).toBeTruthy();
|
|
503
|
+
expect(screen.queryByText("古い Issue 123 の load 応答")).toBeNull();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("poll でスナップショット・本文が未変化なら onExternalChange を呼ばない", async () => {
|
|
507
|
+
const onExternalChange = vi.fn();
|
|
508
|
+
render(
|
|
509
|
+
<IssueDetailView
|
|
510
|
+
issueNumber={123}
|
|
511
|
+
onCreated={vi.fn()}
|
|
512
|
+
onClose={vi.fn()}
|
|
513
|
+
onExternalChange={onExternalChange}
|
|
514
|
+
syncEnabled
|
|
515
|
+
/>,
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
await waitFor(() => {
|
|
519
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// #980: 既定モック(beforeEach の mockResolvedValue(MOCK_ISSUE))が全 poll で未変化を返すため、
|
|
523
|
+
// 呼び出し順依存の mockResolvedValueOnce は不要。triggered poll も MOCK_ISSUE のままで決定的。
|
|
524
|
+
await act(async () => {
|
|
525
|
+
triggerPeriodicSync();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(onExternalChange).not.toHaveBeenCalled();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("syncEnabled=false のときポーリングで追加 API を呼ばない(REQ-016-AC-03)", async () => {
|
|
532
|
+
render(
|
|
533
|
+
<IssueDetailView
|
|
534
|
+
issueNumber={123}
|
|
535
|
+
onCreated={vi.fn()}
|
|
536
|
+
onClose={vi.fn()}
|
|
537
|
+
syncEnabled={false}
|
|
538
|
+
/>,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
await waitFor(() => {
|
|
542
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const callCount = mockGetIssue.mock.calls.length;
|
|
546
|
+
|
|
547
|
+
await act(async () => {
|
|
548
|
+
triggerPeriodicSync();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
expect(mockGetIssue.mock.calls.length).toBe(callCount);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("編集中の label-mutate load は form を上書きせず、in-flight の遅い poll でも snapshot を巻き戻さない(#987)", async () => {
|
|
555
|
+
let resolvePoll: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
556
|
+
// #1006: phantom mount poll が無いため getIssue は load(1) → triggered poll(2, in-flight 遅延)
|
|
557
|
+
// → label-mutate load(3, "ラベル更新後") の 3 回。2 番目を in-flight の遅い poll にする。
|
|
558
|
+
mockGetIssue
|
|
559
|
+
.mockResolvedValueOnce(MOCK_ISSUE)
|
|
560
|
+
.mockImplementationOnce(
|
|
561
|
+
() =>
|
|
562
|
+
new Promise((resolve) => {
|
|
563
|
+
resolvePoll = resolve;
|
|
564
|
+
}),
|
|
565
|
+
)
|
|
566
|
+
.mockResolvedValue({
|
|
567
|
+
...MOCK_ISSUE,
|
|
568
|
+
title: "ラベル更新後",
|
|
569
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
render(
|
|
573
|
+
<IssueDetailView
|
|
574
|
+
issueNumber={123}
|
|
575
|
+
onCreated={vi.fn()}
|
|
576
|
+
onClose={vi.fn()}
|
|
577
|
+
syncEnabled
|
|
578
|
+
/>,
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
await waitFor(() => {
|
|
582
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
await act(async () => {
|
|
586
|
+
triggerPeriodicSync();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
fireEvent.click(screen.getByText("編集"));
|
|
590
|
+
fireEvent.click(screen.getByTestId("trigger-label-mutate"));
|
|
591
|
+
|
|
592
|
+
// #987: 編集中は label-mutate 起因の load("ラベル更新後")が成功しても form を上書きしない。
|
|
593
|
+
// snapshot(issue)は同期するが、入力中の form 値は保持される。
|
|
594
|
+
await waitFor(() => expect(mockGetIssue).toHaveBeenCalledTimes(3));
|
|
595
|
+
await act(async () => {
|
|
596
|
+
await Promise.resolve();
|
|
597
|
+
});
|
|
598
|
+
expect(
|
|
599
|
+
(screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement).value,
|
|
600
|
+
).toBe("テスト Issue");
|
|
601
|
+
|
|
602
|
+
// in-flight の遅い poll(取得開始は load より前)が解決しても、編集中は反映されない
|
|
603
|
+
await act(async () => {
|
|
604
|
+
resolvePoll?.({
|
|
605
|
+
...MOCK_ISSUE,
|
|
606
|
+
title: "poll の古い応答",
|
|
607
|
+
updatedAt: "2026-06-02T01:00:00Z",
|
|
608
|
+
});
|
|
609
|
+
await Promise.resolve();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// 編集中は遅延 poll が editingRef ガードで反映されないため、snapshot は load の "ラベル更新後" を保持。
|
|
613
|
+
// キャンセルでその snapshot へ戻る(load が反映され、in-flight の遅延 poll では巻き戻らない)。
|
|
614
|
+
fireEvent.click(screen.getByText("キャンセル"));
|
|
615
|
+
await waitFor(() => expect(screen.getByText("ラベル更新後")).toBeTruthy());
|
|
616
|
+
expect(screen.queryByText("poll の古い応答")).toBeNull();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("編集中のラベル変更で再取得した foreground load が更新前(stale)データを返しても巻き戻さない(#978)", async () => {
|
|
620
|
+
const NEW = { ...MOCK_ISSUE, title: "新しいタイトル", updatedAt: "2026-06-10T00:00:00Z" };
|
|
621
|
+
const STALE = { ...MOCK_ISSUE, title: "古いタイトル", updatedAt: "2026-05-19T00:00:00Z" };
|
|
622
|
+
// #1006: phantom mount poll が無いため、初回 foreground load が NEW(updatedAt=2026-06-10)。
|
|
623
|
+
// onLabelsMutated 起因の再取得(loadIssue)は eventual consistency で更新前(stale)の STALE を返す。
|
|
624
|
+
mockGetIssue.mockResolvedValueOnce(NEW).mockResolvedValue(STALE);
|
|
625
|
+
|
|
626
|
+
render(
|
|
627
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} syncEnabled />,
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
await waitFor(() => expect(screen.getByText("新しいタイトル")).toBeTruthy());
|
|
631
|
+
|
|
632
|
+
fireEvent.click(screen.getByText("編集"));
|
|
633
|
+
await waitFor(() => expect(screen.getByDisplayValue("新しいタイトル")).toBeTruthy());
|
|
634
|
+
|
|
635
|
+
// ラベル変更 → loadIssue 再取得(STALE 応答)
|
|
636
|
+
fireEvent.click(screen.getByTestId("trigger-label-mutate"));
|
|
637
|
+
await waitFor(() => expect(mockGetIssue).toHaveBeenCalledTimes(2));
|
|
638
|
+
await act(async () => {
|
|
639
|
+
await Promise.resolve();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// stale 応答は反映されず、編集中の表示が更新前タイトルへ巻き戻らない
|
|
643
|
+
await waitFor(() => expect(screen.getByDisplayValue("新しいタイトル")).toBeTruthy());
|
|
644
|
+
expect(screen.queryByDisplayValue("古いタイトル")).toBeNull();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("編集中のラベル変更で fresh な loadIssue が走っても入力途中の title/body/selectedLabels を上書きしない(#987 / REQ-016-AC-17)", async () => {
|
|
648
|
+
// 初回 load + mount 直後の即時 poll は LABELED(labels=["bug"])。onLabelsMutated 起因の再取得(loadIssue)は
|
|
649
|
+
// stale でない(より新しい updatedAt)正規応答 FRESH(title/body 変更・labels=["enhancement"])を返す
|
|
650
|
+
// (happy path・stale ガードでは防げない経路)。
|
|
651
|
+
const LABELED = { ...MOCK_ISSUE, labels: [{ name: "bug", color: "d73a4a" }] };
|
|
652
|
+
const FRESH = {
|
|
653
|
+
...MOCK_ISSUE,
|
|
654
|
+
title: "server タイトル",
|
|
655
|
+
body: "server 本文",
|
|
656
|
+
labels: [{ name: "enhancement", color: "a2eeef" }],
|
|
657
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
658
|
+
};
|
|
659
|
+
// #1006: phantom mount poll が無いため、初回 load=LABELED、label-mutate load=FRESH の 2 回。
|
|
660
|
+
mockGetIssue
|
|
661
|
+
.mockResolvedValueOnce(LABELED)
|
|
662
|
+
.mockResolvedValue(FRESH);
|
|
663
|
+
|
|
664
|
+
render(
|
|
665
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} syncEnabled />,
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
await waitFor(() => expect(screen.getByText("テスト Issue")).toBeTruthy());
|
|
669
|
+
|
|
670
|
+
fireEvent.click(screen.getByText("編集"));
|
|
671
|
+
fireEvent.change(screen.getByPlaceholderText("Issue のタイトル"), {
|
|
672
|
+
target: { value: "編集中タイトル" },
|
|
673
|
+
});
|
|
674
|
+
fireEvent.change(screen.getByPlaceholderText("Issue の詳細を Markdown で記述…"), {
|
|
675
|
+
target: { value: "編集中本文" },
|
|
676
|
+
});
|
|
677
|
+
// 編集開始時点の selectedLabels は issue のラベル("bug")
|
|
678
|
+
expect(screen.getByTestId("selected-labels").textContent).toBe("bug");
|
|
679
|
+
|
|
680
|
+
// ラベル変更 → fresh(非 stale)な loadIssue が happy path で成功する
|
|
681
|
+
fireEvent.click(screen.getByTestId("trigger-label-mutate"));
|
|
682
|
+
await waitFor(() => expect(mockGetIssue).toHaveBeenCalledTimes(2));
|
|
683
|
+
await act(async () => {
|
|
684
|
+
await Promise.resolve();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// 入力途中の title / body / selectedLabels は server 値("server タイトル" / "enhancement")で上書きされない(編集中ガード)
|
|
688
|
+
expect(
|
|
689
|
+
(screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement).value,
|
|
690
|
+
).toBe("編集中タイトル");
|
|
691
|
+
expect(
|
|
692
|
+
(screen.getByPlaceholderText("Issue の詳細を Markdown で記述…") as HTMLTextAreaElement).value,
|
|
693
|
+
).toBe("編集中本文");
|
|
694
|
+
expect(screen.getByTestId("selected-labels").textContent).toBe("bug");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("より新しい background poll が先に成功したら、後着の古い foreground load では上書きしない(#949 / REQ-016-AC-13)", async () => {
|
|
698
|
+
const NEWER = { ...MOCK_ISSUE, title: "poll-newer-title" };
|
|
699
|
+
const STALE = { ...MOCK_ISSUE, title: "stale-load-title" };
|
|
700
|
+
let resolveLoad: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
701
|
+
let calls = 0;
|
|
702
|
+
mockGetIssue.mockImplementation(() => {
|
|
703
|
+
calls += 1;
|
|
704
|
+
// call1 = foreground 初回 load(後着・保留・古い)
|
|
705
|
+
if (calls === 1) {
|
|
706
|
+
return new Promise((resolve) => {
|
|
707
|
+
resolveLoad = resolve;
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
// call2 = triggered background poll(先着・成功・新しい)
|
|
711
|
+
return Promise.resolve(NEWER);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
render(
|
|
715
|
+
<IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} syncEnabled />,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
// #1006: skipInitialSync=true では mount で即時 poll は走らないため、in-flight の foreground load
|
|
719
|
+
// (call1)が dispatch されたのを待ってから、interval 相当の background poll を明示発火する。
|
|
720
|
+
// load は保留のまま poll(call2=NEWER)が先に解決する競合を本番忠実に再現する。
|
|
721
|
+
await waitFor(() => expect(mockGetIssue).toHaveBeenCalledTimes(1));
|
|
722
|
+
await act(async () => {
|
|
723
|
+
triggerPeriodicSync();
|
|
724
|
+
});
|
|
725
|
+
await waitFor(() => expect(mockGetIssue).toHaveBeenCalledTimes(2));
|
|
726
|
+
|
|
727
|
+
// 後着の古い foreground load が解決しても、より新しい poll の結果を上書きしない(freshness 後勝ち)
|
|
728
|
+
await act(async () => {
|
|
729
|
+
resolveLoad?.(STALE);
|
|
730
|
+
await Promise.resolve();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
await waitFor(() => {
|
|
734
|
+
expect(screen.getByText("poll-newer-title")).toBeTruthy();
|
|
735
|
+
});
|
|
736
|
+
expect(screen.queryByText("stale-load-title")).toBeNull();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("保存成功後は in-flight poll の古い応答を適用しない", async () => {
|
|
740
|
+
let resolvePoll: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
741
|
+
const savedIssue = {
|
|
742
|
+
...MOCK_ISSUE,
|
|
743
|
+
title: "保存後タイトル",
|
|
744
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
745
|
+
};
|
|
746
|
+
// #1006: phantom mount poll が無いため、保存より前に開始する in-flight poll を明示発火する
|
|
747
|
+
// (call2)。この poll は保存より前の世代なので、後で(より新しい updatedAt の)古い応答で解決しても
|
|
748
|
+
// freshness ゲート(gen <= appliedGen)で棄却され、保存結果を巻き戻さない。
|
|
749
|
+
mockGetIssue
|
|
750
|
+
.mockResolvedValueOnce(MOCK_ISSUE)
|
|
751
|
+
.mockImplementationOnce(
|
|
752
|
+
() =>
|
|
753
|
+
new Promise((resolve) => {
|
|
754
|
+
resolvePoll = resolve;
|
|
755
|
+
}),
|
|
756
|
+
)
|
|
757
|
+
.mockResolvedValue(savedIssue);
|
|
758
|
+
mockUpdateIssue.mockResolvedValue(savedIssue);
|
|
759
|
+
|
|
760
|
+
render(
|
|
761
|
+
<IssueDetailView
|
|
762
|
+
issueNumber={123}
|
|
763
|
+
onCreated={vi.fn()}
|
|
764
|
+
onClose={vi.fn()}
|
|
765
|
+
syncEnabled
|
|
766
|
+
/>,
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
await waitFor(() => {
|
|
770
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
await act(async () => {
|
|
774
|
+
triggerPeriodicSync();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
fireEvent.click(screen.getByText("編集"));
|
|
778
|
+
|
|
779
|
+
await waitFor(() => {
|
|
780
|
+
expect(screen.getByDisplayValue("テスト Issue")).toBeTruthy();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const titleInput = screen.getByPlaceholderText("Issue のタイトル") as HTMLInputElement;
|
|
784
|
+
fireEvent.change(titleInput, { target: { value: "保存後タイトル" } });
|
|
785
|
+
|
|
786
|
+
await act(async () => {
|
|
787
|
+
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
await waitFor(() => {
|
|
791
|
+
expect(mockUpdateIssue).toHaveBeenCalled();
|
|
792
|
+
expect(screen.getByText("保存後タイトル")).toBeTruthy();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
await act(async () => {
|
|
796
|
+
resolvePoll?.({
|
|
797
|
+
...MOCK_ISSUE,
|
|
798
|
+
title: "poll の古い応答",
|
|
799
|
+
updatedAt: "2026-06-02T01:00:00Z",
|
|
800
|
+
});
|
|
801
|
+
await Promise.resolve();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
expect(screen.getByText("保存後タイトル")).toBeTruthy();
|
|
805
|
+
expect(screen.queryByText("poll の古い応答")).toBeNull();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("保存後に開始した poll が eventual consistency で更新前データを返しても巻き戻さない(#971)", async () => {
|
|
809
|
+
const onExternalChange = vi.fn();
|
|
810
|
+
const SAVED = {
|
|
811
|
+
...MOCK_ISSUE,
|
|
812
|
+
title: "保存後タイトル",
|
|
813
|
+
updatedAt: "2026-06-10T00:00:00Z",
|
|
814
|
+
};
|
|
815
|
+
mockUpdateIssue.mockResolvedValue(SAVED);
|
|
816
|
+
// 既定 getIssue は更新前(stale)の MOCK_ISSUE(updatedAt=2026-05-19)を返し続ける。
|
|
817
|
+
// 保存で editing=false になると poll が再有効化され、この stale を取得する。
|
|
818
|
+
mockGetIssue.mockResolvedValue(MOCK_ISSUE);
|
|
819
|
+
|
|
820
|
+
render(
|
|
821
|
+
<IssueDetailView
|
|
822
|
+
issueNumber={123}
|
|
823
|
+
onCreated={vi.fn()}
|
|
824
|
+
onClose={vi.fn()}
|
|
825
|
+
onExternalChange={onExternalChange}
|
|
826
|
+
syncEnabled
|
|
827
|
+
/>,
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
await waitFor(() => expect(screen.getByText("テスト Issue")).toBeTruthy());
|
|
831
|
+
|
|
832
|
+
// 編集 → 保存(saved データ表示・foreground 通知1回)
|
|
833
|
+
fireEvent.click(screen.getByText("編集"));
|
|
834
|
+
fireEvent.change(screen.getByPlaceholderText("Issue のタイトル"), {
|
|
835
|
+
target: { value: "保存後タイトル" },
|
|
836
|
+
});
|
|
837
|
+
await act(async () => {
|
|
838
|
+
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
await waitFor(() => expect(screen.getByText("保存後タイトル")).toBeTruthy());
|
|
842
|
+
const callsAfterSave = onExternalChange.mock.calls.length;
|
|
843
|
+
|
|
844
|
+
// 保存直後に再有効化された poll が stale(更新前 updatedAt)を返す
|
|
845
|
+
await act(async () => {
|
|
846
|
+
triggerPeriodicSync();
|
|
847
|
+
await Promise.resolve();
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// 巻き戻らず saved データを維持し、spurious な onExternalChange も発火しない
|
|
851
|
+
expect(screen.getByText("保存後タイトル")).toBeTruthy();
|
|
852
|
+
expect(screen.queryByText("テスト Issue")).toBeNull();
|
|
853
|
+
expect(onExternalChange.mock.calls.length).toBe(callsAfterSave);
|
|
854
|
+
|
|
855
|
+
// eventual consistency 解消後の poll では通常どおり最新が反映される
|
|
856
|
+
mockGetIssue.mockResolvedValue({
|
|
857
|
+
...SAVED,
|
|
858
|
+
body: "GitHub 反映後",
|
|
859
|
+
updatedAt: "2026-06-11T00:00:00Z",
|
|
860
|
+
});
|
|
861
|
+
await act(async () => {
|
|
862
|
+
triggerPeriodicSync();
|
|
863
|
+
await Promise.resolve();
|
|
864
|
+
});
|
|
865
|
+
await waitFor(() => expect(screen.getByText("GitHub 反映後")).toBeTruthy());
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("同一 updatedAt で内容が後退する poll は反映せず onExternalChange も呼ばない(#979 / REQ-016-AC-16)", async () => {
|
|
869
|
+
const onExternalChange = vi.fn();
|
|
870
|
+
const SAVED = {
|
|
871
|
+
...MOCK_ISSUE,
|
|
872
|
+
title: "保存後タイトル",
|
|
873
|
+
updatedAt: "2026-06-10T00:00:00Z",
|
|
874
|
+
};
|
|
875
|
+
// EC で同一秒(updatedAt 一致)だが内容が後退した(旧タイトル)応答
|
|
876
|
+
const EQUAL_STALE = {
|
|
877
|
+
...MOCK_ISSUE,
|
|
878
|
+
title: "古いタイトル",
|
|
879
|
+
updatedAt: "2026-06-10T00:00:00Z",
|
|
880
|
+
};
|
|
881
|
+
mockUpdateIssue.mockResolvedValue(SAVED);
|
|
882
|
+
mockGetIssue.mockResolvedValue(EQUAL_STALE);
|
|
883
|
+
|
|
884
|
+
render(
|
|
885
|
+
<IssueDetailView
|
|
886
|
+
issueNumber={123}
|
|
887
|
+
onCreated={vi.fn()}
|
|
888
|
+
onClose={vi.fn()}
|
|
889
|
+
onExternalChange={onExternalChange}
|
|
890
|
+
syncEnabled
|
|
891
|
+
/>,
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
await waitFor(() => expect(screen.getByText("古いタイトル")).toBeTruthy());
|
|
895
|
+
|
|
896
|
+
// 編集 → 保存(saved データ updatedAt=2026-06-10・内容前進)
|
|
897
|
+
fireEvent.click(screen.getByText("編集"));
|
|
898
|
+
fireEvent.change(screen.getByPlaceholderText("Issue のタイトル"), {
|
|
899
|
+
target: { value: "保存後タイトル" },
|
|
900
|
+
});
|
|
901
|
+
await act(async () => {
|
|
902
|
+
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
903
|
+
});
|
|
904
|
+
await waitFor(() => expect(screen.getByText("保存後タイトル")).toBeTruthy());
|
|
905
|
+
const callsAfterSave = onExternalChange.mock.calls.length;
|
|
906
|
+
|
|
907
|
+
// 保存直後に再有効化された poll が、同一 updatedAt だが内容後退の EQUAL_STALE を返す
|
|
908
|
+
await act(async () => {
|
|
909
|
+
triggerPeriodicSync();
|
|
910
|
+
await Promise.resolve();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// 同一秒の stale 読取は反映せず、saved を維持し spurious な onExternalChange も発火しない
|
|
914
|
+
expect(screen.getByText("保存後タイトル")).toBeTruthy();
|
|
915
|
+
expect(screen.queryByText("古いタイトル")).toBeNull();
|
|
916
|
+
expect(onExternalChange.mock.calls.length).toBe(callsAfterSave);
|
|
917
|
+
|
|
918
|
+
// 厳密に新しい updatedAt の poll では通常どおり反映(EC 解消後に自己回復)
|
|
919
|
+
mockGetIssue.mockResolvedValue({
|
|
920
|
+
...SAVED,
|
|
921
|
+
title: "GitHub 反映後",
|
|
922
|
+
updatedAt: "2026-06-11T00:00:00Z",
|
|
923
|
+
});
|
|
924
|
+
await act(async () => {
|
|
925
|
+
triggerPeriodicSync();
|
|
926
|
+
await Promise.resolve();
|
|
927
|
+
});
|
|
928
|
+
await waitFor(() => expect(screen.getByText("GitHub 反映後")).toBeTruthy());
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it("クローズ後に開始した poll が eventual consistency で更新前データを返しても巻き戻さない(#971 / REQ-016-AC-14)", async () => {
|
|
932
|
+
const onExternalChange = vi.fn();
|
|
933
|
+
const CLOSED = {
|
|
934
|
+
...MOCK_ISSUE,
|
|
935
|
+
state: "closed" as const,
|
|
936
|
+
updatedAt: "2026-06-10T00:00:00Z",
|
|
937
|
+
};
|
|
938
|
+
mockUpdateIssue.mockResolvedValue(CLOSED);
|
|
939
|
+
mockGetIssue.mockResolvedValue(MOCK_ISSUE);
|
|
940
|
+
|
|
941
|
+
render(
|
|
942
|
+
<IssueDetailView
|
|
943
|
+
issueNumber={123}
|
|
944
|
+
onCreated={vi.fn()}
|
|
945
|
+
onClose={vi.fn()}
|
|
946
|
+
onExternalChange={onExternalChange}
|
|
947
|
+
syncEnabled
|
|
948
|
+
/>,
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
await waitFor(() => expect(screen.getByText("テスト Issue")).toBeTruthy());
|
|
952
|
+
expect(screen.getByRole("button", { name: "クローズ" })).toBeTruthy();
|
|
953
|
+
|
|
954
|
+
await act(async () => {
|
|
955
|
+
fireEvent.click(screen.getByRole("button", { name: "クローズ" }));
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "再オープン" })).toBeTruthy());
|
|
959
|
+
const callsAfterClose = onExternalChange.mock.calls.length;
|
|
960
|
+
|
|
961
|
+
await act(async () => {
|
|
962
|
+
triggerPeriodicSync();
|
|
963
|
+
await Promise.resolve();
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
expect(screen.getByRole("button", { name: "再オープン" })).toBeTruthy();
|
|
967
|
+
expect(screen.queryByRole("button", { name: "クローズ" })).toBeNull();
|
|
968
|
+
expect(onExternalChange.mock.calls.length).toBe(callsAfterClose);
|
|
969
|
+
|
|
970
|
+
mockGetIssue.mockResolvedValue({
|
|
971
|
+
...CLOSED,
|
|
972
|
+
body: "GitHub 反映後",
|
|
973
|
+
updatedAt: "2026-06-11T00:00:00Z",
|
|
974
|
+
});
|
|
975
|
+
await act(async () => {
|
|
976
|
+
triggerPeriodicSync();
|
|
977
|
+
await Promise.resolve();
|
|
978
|
+
});
|
|
979
|
+
await waitFor(() => expect(screen.getByText("GitHub 反映後")).toBeTruthy());
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it("stale poll が appliedGen を消費せず、より早く開始した in-flight poll の正当な結果を棄却しない(#971)", async () => {
|
|
983
|
+
const SAVED = {
|
|
984
|
+
...MOCK_ISSUE,
|
|
985
|
+
title: "保存後タイトル",
|
|
986
|
+
updatedAt: "2026-06-10T00:00:00Z",
|
|
987
|
+
};
|
|
988
|
+
const CONSOLIDATED = {
|
|
989
|
+
...SAVED,
|
|
990
|
+
body: "GitHub 反映後",
|
|
991
|
+
updatedAt: "2026-06-11T00:00:00Z",
|
|
992
|
+
};
|
|
993
|
+
mockUpdateIssue.mockResolvedValue(SAVED);
|
|
994
|
+
|
|
995
|
+
let saved = false;
|
|
996
|
+
let resolveSlowPoll: ((value: typeof CONSOLIDATED) => void) | undefined;
|
|
997
|
+
let pollCallsAfterSave = 0;
|
|
998
|
+
|
|
999
|
+
mockGetIssue.mockImplementation(() => {
|
|
1000
|
+
if (!saved) return Promise.resolve(MOCK_ISSUE);
|
|
1001
|
+
pollCallsAfterSave += 1;
|
|
1002
|
+
// より早く開始した poll(遅延・整合後データ)
|
|
1003
|
+
if (pollCallsAfterSave === 1) {
|
|
1004
|
+
return new Promise((resolve) => {
|
|
1005
|
+
resolveSlowPoll = resolve;
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
// より後に開始した poll(即時・stale)
|
|
1009
|
+
return Promise.resolve(MOCK_ISSUE);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
render(
|
|
1013
|
+
<IssueDetailView
|
|
1014
|
+
issueNumber={123}
|
|
1015
|
+
onCreated={vi.fn()}
|
|
1016
|
+
onClose={vi.fn()}
|
|
1017
|
+
syncEnabled
|
|
1018
|
+
/>,
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
await waitFor(() => expect(screen.getByText("テスト Issue")).toBeTruthy());
|
|
1022
|
+
|
|
1023
|
+
fireEvent.click(screen.getByText("編集"));
|
|
1024
|
+
fireEvent.change(screen.getByPlaceholderText("Issue のタイトル"), {
|
|
1025
|
+
target: { value: "保存後タイトル" },
|
|
1026
|
+
});
|
|
1027
|
+
await act(async () => {
|
|
1028
|
+
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
1029
|
+
});
|
|
1030
|
+
await waitFor(() => expect(screen.getByText("保存後タイトル")).toBeTruthy());
|
|
1031
|
+
|
|
1032
|
+
saved = true;
|
|
1033
|
+
// editing=false で enabled が再有効化され、遅い poll が自動開始される
|
|
1034
|
+
await act(async () => {
|
|
1035
|
+
await Promise.resolve();
|
|
1036
|
+
});
|
|
1037
|
+
// より後に開始した poll が stale を即時返す
|
|
1038
|
+
await act(async () => {
|
|
1039
|
+
triggerPeriodicSync();
|
|
1040
|
+
await Promise.resolve();
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
expect(screen.getByText("保存後タイトル")).toBeTruthy();
|
|
1044
|
+
expect(screen.queryByText("GitHub 反映後")).toBeNull();
|
|
1045
|
+
|
|
1046
|
+
await act(async () => {
|
|
1047
|
+
resolveSlowPoll?.(CONSOLIDATED);
|
|
1048
|
+
await Promise.resolve();
|
|
1049
|
+
});
|
|
1050
|
+
await waitFor(() => expect(screen.getByText("GitHub 反映後")).toBeTruthy());
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("保存 API 応答待ち中に in-flight poll が完了しても state を巻き戻さない", async () => {
|
|
1054
|
+
let resolvePoll: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
1055
|
+
let resolveUpdate: ((value: typeof MOCK_ISSUE) => void) | undefined;
|
|
1056
|
+
const savedIssue = {
|
|
1057
|
+
...MOCK_ISSUE,
|
|
1058
|
+
title: "保存後タイトル",
|
|
1059
|
+
updatedAt: "2026-06-02T00:00:00Z",
|
|
1060
|
+
};
|
|
1061
|
+
mockGetIssue
|
|
1062
|
+
.mockResolvedValueOnce(MOCK_ISSUE)
|
|
1063
|
+
.mockImplementationOnce(
|
|
1064
|
+
() =>
|
|
1065
|
+
new Promise((resolve) => {
|
|
1066
|
+
resolvePoll = resolve;
|
|
1067
|
+
}),
|
|
1068
|
+
)
|
|
1069
|
+
.mockResolvedValue(savedIssue);
|
|
1070
|
+
mockUpdateIssue.mockImplementation(
|
|
1071
|
+
() =>
|
|
1072
|
+
new Promise((resolve) => {
|
|
1073
|
+
resolveUpdate = resolve;
|
|
1074
|
+
}),
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
render(
|
|
1078
|
+
<IssueDetailView
|
|
1079
|
+
issueNumber={123}
|
|
1080
|
+
onCreated={vi.fn()}
|
|
1081
|
+
onClose={vi.fn()}
|
|
1082
|
+
syncEnabled
|
|
1083
|
+
/>,
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
await waitFor(() => {
|
|
1087
|
+
expect(screen.getByText("テスト Issue")).toBeTruthy();
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
fireEvent.click(screen.getByText("編集"));
|
|
1091
|
+
fireEvent.change(screen.getByPlaceholderText("Issue のタイトル"), {
|
|
1092
|
+
target: { value: "保存後タイトル" },
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
await act(async () => {
|
|
1096
|
+
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
await act(async () => {
|
|
1100
|
+
resolvePoll?.({
|
|
1101
|
+
...MOCK_ISSUE,
|
|
1102
|
+
title: "poll の古い応答",
|
|
1103
|
+
updatedAt: "2026-06-02T01:00:00Z",
|
|
1104
|
+
});
|
|
1105
|
+
await Promise.resolve();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
await act(async () => {
|
|
1109
|
+
resolveUpdate?.(savedIssue);
|
|
1110
|
+
await Promise.resolve();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
await waitFor(() => {
|
|
1114
|
+
expect(screen.getByText("保存後タイトル")).toBeTruthy();
|
|
1115
|
+
});
|
|
1116
|
+
expect(screen.queryByText("poll の古い応答")).toBeNull();
|
|
1117
|
+
});
|
|
1118
|
+
});
|