Spaces:
Running
Running
Commit ·
a4062c9
1
Parent(s): 5cab93a
remove ui tests, keep api tests only
Browse files- CLAUDE.md +11 -69
- README.md +6 -14
- TODO.md +0 -212
- docs/PROMPT_MANAGEMENT_DESIGN.md +2 -2
- docs/backend-doc/12-database-integration.md +3 -3
- package.json +0 -4
- playwright.config.ts +1 -8
- tests/e2e/{admin-conversation-export-concurrent.spec.ts → admin-conversation-export-concurrent.api.spec.ts} +0 -0
- tests/e2e/admin-conversation-export-ui.spec.ts +0 -365
- tests/e2e/{admin-conversation-export.spec.ts → admin-conversation-export.api.spec.ts} +20 -13
- tests/e2e/admin-conversation-speaker-display.spec.ts +0 -236
- tests/e2e/admin-conversations.ui.spec.ts +0 -336
- tests/e2e/admin-create-student-prompt.spec.ts +0 -128
- tests/e2e/admin-dashboard.ui.spec.ts +0 -100
- tests/e2e/admin-users.ui.spec.ts +0 -175
- tests/e2e/coach-ad-prompt.spec.ts +0 -315
- tests/e2e/coach-chat-persistence.spec.ts +0 -145
- tests/e2e/coach-chat-ui-only.spec.ts +0 -248
- tests/e2e/coach-conversation-messaging.spec.ts +0 -124
- tests/e2e/coach-conversation-speaker-default.spec.ts +0 -188
- tests/e2e/coach-conversation-ui-buttons.spec.ts +0 -244
- tests/e2e/coach-guidance-mode.spec.ts +0 -192
- tests/e2e/complete-ui-flow.spec.ts +0 -75
- tests/e2e/{concurrency-race-conditions.spec.ts → concurrency-race-conditions.api.spec.ts} +0 -0
- tests/e2e/conversation-delete.spec.ts +0 -351
- tests/e2e/conversation-titles.spec.ts +0 -329
- tests/e2e/dashboard-many-conversations.spec.ts +0 -334
- tests/e2e/dashboard.spec.ts +0 -219
- tests/e2e/message-filter.spec.ts +0 -231
- tests/e2e/prompts-in-conversations.spec.ts +0 -343
- tests/e2e/quoted-replies.spec.ts +0 -273
- tests/e2e/{removed-template-handling.spec.ts → removed-template-handling.api.spec.ts} +0 -0
- tests/e2e/{response-id-expiration.spec.ts → response-id-expiration.api.spec.ts} +0 -0
- tests/e2e/speaker-auto-detection.spec.ts +0 -151
- tests/e2e/speaker-toggle-bug.spec.ts +0 -126
- tests/e2e/{title-generation-logic.spec.ts → title-generation-logic.api.spec.ts} +0 -0
- tests/e2e/ui-auth-redirect.spec.ts +0 -142
- tests/e2e/verify-no-nested-buttons.spec.ts +0 -28
CLAUDE.md
CHANGED
|
@@ -24,11 +24,7 @@ npm run build:ci # Build + integration tests
|
|
| 24 |
npm start # Start production server
|
| 25 |
|
| 26 |
# Testing
|
| 27 |
-
npm run test:api # Run API tests
|
| 28 |
-
npm run test:ui # Run UI tests only (Playwright)
|
| 29 |
-
npm run test:e2e # Run all E2E tests (API + UI)
|
| 30 |
-
npm run test:e2e:headed # Run E2E tests with visible browser
|
| 31 |
-
npm run test:e2e:ui # Run E2E tests in interactive UI mode
|
| 32 |
|
| 33 |
# Linting
|
| 34 |
npm run lint # Run ESLint
|
|
@@ -237,7 +233,7 @@ TypeScript paths configured in `tsconfig.json`:
|
|
| 237 |
|
| 238 |
**Fail-fast testing approach:**
|
| 239 |
When running tests to verify features, use a fail-fast approach to save time:
|
| 240 |
-
1. Start the test suite (`npm run test:
|
| 241 |
2. As soon as the first test failure appears, **stop the test run immediately**
|
| 242 |
3. Investigate and fix the root cause of that failure
|
| 243 |
4. Re-run tests to verify the fix
|
|
@@ -245,38 +241,6 @@ When running tests to verify features, use a fail-fast approach to save time:
|
|
| 245 |
|
| 246 |
**Rationale:** If there's a fundamental issue (e.g., server not starting, auth broken, missing dependency), continuing to run all tests just wastes time and produces misleading failure cascades. Failing fast, fixing the root cause, then re-running is far more efficient.
|
| 247 |
|
| 248 |
-
**Test Environment Cleanup:**
|
| 249 |
-
⚠️ **CRITICAL**: Before running the full E2E test suite, always ensure a clean environment to avoid false test failures:
|
| 250 |
-
|
| 251 |
-
1. **Kill all background processes:**
|
| 252 |
-
```bash
|
| 253 |
-
pkill -f "npm run dev" || true
|
| 254 |
-
pkill -f "playwright test" || true
|
| 255 |
-
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
| 256 |
-
```
|
| 257 |
-
|
| 258 |
-
2. **Clean database state:**
|
| 259 |
-
```bash
|
| 260 |
-
rm -f ./data/app.db
|
| 261 |
-
```
|
| 262 |
-
|
| 263 |
-
3. **Run tests:**
|
| 264 |
-
```bash
|
| 265 |
-
npm run test:e2e
|
| 266 |
-
```
|
| 267 |
-
|
| 268 |
-
**Why this matters:**
|
| 269 |
-
- Multiple concurrent dev servers or test processes cause port conflicts and resource contention
|
| 270 |
-
- Stale database entries from previous test runs can cause validation failures
|
| 271 |
-
- Test data accumulation leads to incorrect test counts and failures
|
| 272 |
-
- This issue has occurred multiple times and can cause 6+ spurious test failures
|
| 273 |
-
|
| 274 |
-
**Symptoms of dirty environment:**
|
| 275 |
-
- Database seed verification fails (expected 13 prompts, got 15+)
|
| 276 |
-
- Tests pass individually but fail when run in suite
|
| 277 |
-
- Random timeouts or port binding errors
|
| 278 |
-
- Inconsistent test results between runs
|
| 279 |
-
|
| 280 |
### Testing Philosophy: NEVER Ignore Test Failures
|
| 281 |
|
| 282 |
**⚠️ CRITICAL PRINCIPLE: Failed tests are YOUR responsibility until proven otherwise.**
|
|
@@ -291,8 +255,8 @@ When tests fail after making changes:
|
|
| 291 |
**Bad Practice (What NOT to do):**
|
| 292 |
```
|
| 293 |
❌ "These look like flaky tests, probably unrelated to my changes"
|
| 294 |
-
❌ "
|
| 295 |
-
❌ "Only
|
| 296 |
```
|
| 297 |
|
| 298 |
**Good Practice (What to do):**
|
|
@@ -304,13 +268,6 @@ When tests fail after making changes:
|
|
| 304 |
✅ Only conclude "pre-existing" after finding evidence (e.g., git commit that broke tests)
|
| 305 |
```
|
| 306 |
|
| 307 |
-
**Real Example from This Codebase:**
|
| 308 |
-
Database concurrency changes passed all 58 API tests but failed 13 UI tests. Instead of assuming "unrelated flaky tests", investigation revealed:
|
| 309 |
-
- Commit `a4c0cfb` (Oct 20) changed dashboard titles "諮詢教練" → "教練回顧"
|
| 310 |
-
- Tests weren't updated in that commit
|
| 311 |
-
- Failures were pre-existing, BUT required investigation to confirm
|
| 312 |
-
- Proper fix: Update tests to match current UI + document investigation process
|
| 313 |
-
|
| 314 |
**Key Takeaway:** Time spent investigating is NEVER wasted - it either finds bugs you introduced, or reveals technical debt to fix.
|
| 315 |
|
| 316 |
### Testing Philosophy: Intention is Critical
|
|
@@ -378,10 +335,6 @@ Choose proper design over quick fixes. Database abstractions exist for a reason
|
|
| 378 |
- Each route.ts exports HTTP method functions (GET, POST, etc.)
|
| 379 |
- Request/response use Web standard Request/Response objects
|
| 380 |
|
| 381 |
-
5. **Model Context Protocol (MCP):**
|
| 382 |
-
- Configured in `.mcp.json` for Playwright browser automation
|
| 383 |
-
- Enables UI testing capabilities within Claude Code
|
| 384 |
-
|
| 385 |
## Development Workflow
|
| 386 |
|
| 387 |
1. **Create new student personality:**
|
|
@@ -402,13 +355,13 @@ Choose proper design over quick fixes. Database abstractions exist for a reason
|
|
| 402 |
|
| 403 |
4. **Remove or deprecate student personality:**
|
| 404 |
- See "Student Template Management" section for safe removal process
|
| 405 |
-
- Run `npm run test:
|
| 406 |
- Monitor logs for conversations using removed templates
|
| 407 |
|
| 408 |
5. **Test changes:**
|
| 409 |
- Run `npm run test:basic` for quick validation
|
| 410 |
- Run `npm run test` for full test suite
|
| 411 |
-
- Run `npm run test:
|
| 412 |
|
| 413 |
## Student Template Management
|
| 414 |
|
|
@@ -440,7 +393,7 @@ Student personality templates are defined in `src/lib/prompts/student-prompts.ts
|
|
| 440 |
|
| 441 |
3. **Test Before Removal**: Run the test suite to verify graceful degradation
|
| 442 |
```bash
|
| 443 |
-
npm run test:
|
| 444 |
```
|
| 445 |
|
| 446 |
4. **Remove Template**: Delete the entry from `studentPrompts` in `student-prompts.ts`
|
|
@@ -475,7 +428,7 @@ Student personality templates are defined in `src/lib/prompts/student-prompts.ts
|
|
| 475 |
2. Follow existing template structure (systemPrompt, name, description, tools)
|
| 476 |
3. Test creation and messaging:
|
| 477 |
```bash
|
| 478 |
-
npm run test:
|
| 479 |
```
|
| 480 |
4. Document the new personality in README.md
|
| 481 |
|
|
@@ -486,33 +439,23 @@ Conversations created before the `systemPrompt` storage feature (if any) will:
|
|
| 486 |
2. Fail if template is removed
|
| 487 |
3. **Recommended**: Manually migrate old conversations by regenerating and storing prompts
|
| 488 |
|
| 489 |
-
## End-to-End Testing (Playwright
|
| 490 |
|
| 491 |
**One-time setup:**
|
| 492 |
```bash
|
| 493 |
export CZ_OPENAI_API_KEY=your-key
|
| 494 |
export MODEL_NAME=gpt-5
|
| 495 |
-
npx playwright install --with-deps
|
| 496 |
```
|
| 497 |
|
| 498 |
**Run tests:**
|
| 499 |
```bash
|
| 500 |
-
npm run test:
|
| 501 |
-
npm run test:e2e:headed # headed mode (watch browser) - for debugging
|
| 502 |
-
npm run test:e2e:ui # interactive test UI - for development
|
| 503 |
```
|
| 504 |
|
| 505 |
-
**Headless vs Headed:**
|
| 506 |
-
- **Always use headless mode** (`npm run test:e2e`) for verification and CI/CD
|
| 507 |
-
- Headless is faster (no UI rendering), uses less resources, and is the standard for automated testing
|
| 508 |
-
- Only use headed mode (`npm run test:e2e:headed`) when debugging a specific test failure
|
| 509 |
-
- Only use UI mode (`npm run test:e2e:ui`) when developing new tests interactively
|
| 510 |
-
|
| 511 |
**CI setup (summary):**
|
| 512 |
- Add `CZ_OPENAI_API_KEY` (and optionally `MODEL_NAME`) to job env
|
| 513 |
- `npm ci`
|
| 514 |
-
- `
|
| 515 |
-
- `npm run test:e2e`
|
| 516 |
|
| 517 |
**Troubleshooting:**
|
| 518 |
- **Agents list not empty**: remove local state under `./data` (and optionally `/tmp/ai-sdk-agents.json` if you previously used legacy flows)
|
|
@@ -520,4 +463,3 @@ npm run test:e2e:ui # interactive test UI - for development
|
|
| 520 |
- **LLM 401/429**: verify key and model access; tests run with `workers: 1` to avoid rate limits
|
| 521 |
- **Timeouts**: allow 60–90s for LLM responses
|
| 522 |
- **Port 3000 in use**: stop the other server, or set `reuseExistingServer=false` temporarily
|
| 523 |
-
- **Missing browsers**: rerun `npx playwright install --with-deps`
|
|
|
|
| 24 |
npm start # Start production server
|
| 25 |
|
| 26 |
# Testing
|
| 27 |
+
npm run test:api # Run API tests (Playwright)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# Linting
|
| 30 |
npm run lint # Run ESLint
|
|
|
|
| 233 |
|
| 234 |
**Fail-fast testing approach:**
|
| 235 |
When running tests to verify features, use a fail-fast approach to save time:
|
| 236 |
+
1. Start the test suite (`npm run test:api`)
|
| 237 |
2. As soon as the first test failure appears, **stop the test run immediately**
|
| 238 |
3. Investigate and fix the root cause of that failure
|
| 239 |
4. Re-run tests to verify the fix
|
|
|
|
| 241 |
|
| 242 |
**Rationale:** If there's a fundamental issue (e.g., server not starting, auth broken, missing dependency), continuing to run all tests just wastes time and produces misleading failure cascades. Failing fast, fixing the root cause, then re-running is far more efficient.
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
### Testing Philosophy: NEVER Ignore Test Failures
|
| 245 |
|
| 246 |
**⚠️ CRITICAL PRINCIPLE: Failed tests are YOUR responsibility until proven otherwise.**
|
|
|
|
| 255 |
**Bad Practice (What NOT to do):**
|
| 256 |
```
|
| 257 |
❌ "These look like flaky tests, probably unrelated to my changes"
|
| 258 |
+
❌ "Tests are timing out, must be a pre-existing issue"
|
| 259 |
+
❌ "Only some tests matter, other failures can be ignored"
|
| 260 |
```
|
| 261 |
|
| 262 |
**Good Practice (What to do):**
|
|
|
|
| 268 |
✅ Only conclude "pre-existing" after finding evidence (e.g., git commit that broke tests)
|
| 269 |
```
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
**Key Takeaway:** Time spent investigating is NEVER wasted - it either finds bugs you introduced, or reveals technical debt to fix.
|
| 272 |
|
| 273 |
### Testing Philosophy: Intention is Critical
|
|
|
|
| 335 |
- Each route.ts exports HTTP method functions (GET, POST, etc.)
|
| 336 |
- Request/response use Web standard Request/Response objects
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
## Development Workflow
|
| 339 |
|
| 340 |
1. **Create new student personality:**
|
|
|
|
| 355 |
|
| 356 |
4. **Remove or deprecate student personality:**
|
| 357 |
- See "Student Template Management" section for safe removal process
|
| 358 |
+
- Run `npm run test:api` to verify graceful degradation
|
| 359 |
- Monitor logs for conversations using removed templates
|
| 360 |
|
| 361 |
5. **Test changes:**
|
| 362 |
- Run `npm run test:basic` for quick validation
|
| 363 |
- Run `npm run test` for full test suite
|
| 364 |
+
- Run `npm run test:api` for API tests with real LLM
|
| 365 |
|
| 366 |
## Student Template Management
|
| 367 |
|
|
|
|
| 393 |
|
| 394 |
3. **Test Before Removal**: Run the test suite to verify graceful degradation
|
| 395 |
```bash
|
| 396 |
+
npm run test:api # Includes removed-template-handling.api.spec.ts
|
| 397 |
```
|
| 398 |
|
| 399 |
4. **Remove Template**: Delete the entry from `studentPrompts` in `student-prompts.ts`
|
|
|
|
| 428 |
2. Follow existing template structure (systemPrompt, name, description, tools)
|
| 429 |
3. Test creation and messaging:
|
| 430 |
```bash
|
| 431 |
+
npm run test:api
|
| 432 |
```
|
| 433 |
4. Document the new personality in README.md
|
| 434 |
|
|
|
|
| 439 |
2. Fail if template is removed
|
| 440 |
3. **Recommended**: Manually migrate old conversations by regenerating and storing prompts
|
| 441 |
|
| 442 |
+
## End-to-End Testing (Playwright API Tests)
|
| 443 |
|
| 444 |
**One-time setup:**
|
| 445 |
```bash
|
| 446 |
export CZ_OPENAI_API_KEY=your-key
|
| 447 |
export MODEL_NAME=gpt-5
|
|
|
|
| 448 |
```
|
| 449 |
|
| 450 |
**Run tests:**
|
| 451 |
```bash
|
| 452 |
+
npm run test:api # Run API tests
|
|
|
|
|
|
|
| 453 |
```
|
| 454 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
**CI setup (summary):**
|
| 456 |
- Add `CZ_OPENAI_API_KEY` (and optionally `MODEL_NAME`) to job env
|
| 457 |
- `npm ci`
|
| 458 |
+
- `npm run test:api`
|
|
|
|
| 459 |
|
| 460 |
**Troubleshooting:**
|
| 461 |
- **Agents list not empty**: remove local state under `./data` (and optionally `/tmp/ai-sdk-agents.json` if you previously used legacy flows)
|
|
|
|
| 463 |
- **LLM 401/429**: verify key and model access; tests run with `workers: 1` to avoid rate limits
|
| 464 |
- **Timeouts**: allow 60–90s for LLM responses
|
| 465 |
- **Port 3000 in use**: stop the other server, or set `reuseExistingServer=false` temporarily
|
|
|
README.md
CHANGED
|
@@ -104,14 +104,8 @@ The project uses Playwright for end-to-end testing with separate test suites for
|
|
| 104 |
### Running Tests
|
| 105 |
|
| 106 |
```bash
|
| 107 |
-
# Run
|
| 108 |
npm run test:api
|
| 109 |
-
|
| 110 |
-
# Run only UI tests (when testing browser interactions)
|
| 111 |
-
npm run test:ui
|
| 112 |
-
|
| 113 |
-
# Run all tests (API + UI)
|
| 114 |
-
npm run test:e2e
|
| 115 |
```
|
| 116 |
|
| 117 |
### Test Files
|
|
@@ -133,14 +127,12 @@ Example API tests:
|
|
| 133 |
3. Catches most issues without browser overhead
|
| 134 |
|
| 135 |
**Before committing:**
|
| 136 |
-
1. Run `npm run test:
|
| 137 |
2. Ensures complete system integration
|
| 138 |
-
3. Tests real
|
| 139 |
|
| 140 |
-
**Speed
|
| 141 |
-
- API tests
|
| 142 |
-
- UI tests only: ~60-90 seconds 🐢
|
| 143 |
-
- All tests: ~70-110 seconds
|
| 144 |
|
| 145 |
### Configuration
|
| 146 |
|
|
@@ -372,7 +364,7 @@ To change AI behavior, edit prompts in `src/lib/prompts/`:
|
|
| 372 |
When removing a student personality template:
|
| 373 |
- ✅ **Existing conversations continue working** - System prompts are stored with each conversation during creation
|
| 374 |
- ❌ **New conversations are blocked** - Validation prevents creating conversations with removed templates
|
| 375 |
-
- ⚠️ **Test before removing** - Run `npm run test:
|
| 376 |
- See `CLAUDE.md` "Student Template Management" section for detailed removal process and migration guidance
|
| 377 |
|
| 378 |
### Research Foundation
|
|
|
|
| 104 |
### Running Tests
|
| 105 |
|
| 106 |
```bash
|
| 107 |
+
# Run API tests
|
| 108 |
npm run test:api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
```
|
| 110 |
|
| 111 |
### Test Files
|
|
|
|
| 127 |
3. Catches most issues without browser overhead
|
| 128 |
|
| 129 |
**Before committing:**
|
| 130 |
+
1. Run `npm run test:api` to verify all API functionality
|
| 131 |
2. Ensures complete system integration
|
| 132 |
+
3. Tests real conversation flows with LLM
|
| 133 |
|
| 134 |
+
**Speed:**
|
| 135 |
+
- API tests: ~10-20 seconds ⚡
|
|
|
|
|
|
|
| 136 |
|
| 137 |
### Configuration
|
| 138 |
|
|
|
|
| 364 |
When removing a student personality template:
|
| 365 |
- ✅ **Existing conversations continue working** - System prompts are stored with each conversation during creation
|
| 366 |
- ❌ **New conversations are blocked** - Validation prevents creating conversations with removed templates
|
| 367 |
+
- ⚠️ **Test before removing** - Run `npm run test:api` to verify graceful degradation
|
| 368 |
- See `CLAUDE.md` "Student Template Management" section for detailed removal process and migration guidance
|
| 369 |
|
| 370 |
### Research Foundation
|
TODO.md
DELETED
|
@@ -1,212 +0,0 @@
|
|
| 1 |
-
# TODO: Remove studentName and coachName Fields
|
| 2 |
-
|
| 3 |
-
This document tracks the progress of removing redundant `studentName` and `coachName` fields from the database and deriving them from prompt templates instead.
|
| 4 |
-
|
| 5 |
-
## Context
|
| 6 |
-
|
| 7 |
-
Previously, these fields were stored in the database for quick display, but they can be derived from static template configurations (`student-prompts.ts` and `coach-prompts.ts`). This change:
|
| 8 |
-
- Reduces data redundancy
|
| 9 |
-
- Ensures names always match the source of truth (prompt templates)
|
| 10 |
-
- Simplifies the database schema
|
| 11 |
-
|
| 12 |
-
## Progress
|
| 13 |
-
|
| 14 |
-
### ✅ Completed Tasks
|
| 15 |
-
|
| 16 |
-
- [x] **Database Schema** - Removed `student_name` and `coach_name` columns from `src/lib/db/schema.sql`
|
| 17 |
-
- [x] **TypeScript Models** - Removed fields from `Conversation` interface in `src/lib/types/models.ts`
|
| 18 |
-
- [x] **Repository Methods** - Updated `ConversationRepository.createConversation()` to remove name parameters
|
| 19 |
-
- [x] **API Routes** - Updated 6 routes to derive names from templates:
|
| 20 |
-
- [x] `src/app/api/conversations/create/route.ts` (lines 51-93)
|
| 21 |
-
- [x] `src/app/api/conversations/[conversationId]/branch/route.ts` (lines 52-95)
|
| 22 |
-
- [x] `src/app/api/conversations/route.ts` (lines 17-49)
|
| 23 |
-
- [x] `src/app/api/conversations/[conversationId]/route.ts` (lines 32-115)
|
| 24 |
-
- [x] `src/app/api/stats/route.ts` (lines 39-56)
|
| 25 |
-
- [x] `src/app/api/conversations/[conversationId]/generate-coach-summary/route.ts` (lines 55-62)
|
| 26 |
-
- [x] **Verify Script** - Updated `scripts/verify-backend.sh` to use `grade_3` instead of deprecated `adhd_inattentive`
|
| 27 |
-
- [x] **Database Cleanup** - Deleted old database and cleared Next.js cache
|
| 28 |
-
|
| 29 |
-
### ✅ Completed Verification
|
| 30 |
-
|
| 31 |
-
- [x] **Run Backend Verification**
|
| 32 |
-
```bash
|
| 33 |
-
./scripts/verify-backend.sh
|
| 34 |
-
```
|
| 35 |
-
- ✅ All API endpoints return 200/201 with correct data
|
| 36 |
-
- ✅ Conversation responses include derived `studentName` and `coachName`
|
| 37 |
-
- ✅ Backend verification passing 100%
|
| 38 |
-
|
| 39 |
-
- [x] **Fixed Database Schema**
|
| 40 |
-
- Updated inline schema in `src/lib/db/index.ts` to use `student_prompt_id` and `coach_prompt_id`
|
| 41 |
-
- Removed old `student_name`, `coach_name`, `student_personality`, `coach_id` columns
|
| 42 |
-
- Database recreated with correct schema
|
| 43 |
-
|
| 44 |
-
- [x] **Fixed E2E Test Selectors**
|
| 45 |
-
- Updated 11 test files to use current dashboard UI text
|
| 46 |
-
- Changed selector from `h3:has-text("新對話")` to `h3:has-text("指定對話演練")`
|
| 47 |
-
- Tests now properly find dashboard action buttons
|
| 48 |
-
|
| 49 |
-
- [ ] **Run E2E Tests** (Currently running)
|
| 50 |
-
```bash
|
| 51 |
-
npm run test:e2e
|
| 52 |
-
```
|
| 53 |
-
- Expected: All 105 tests should pass
|
| 54 |
-
- Status: Running full suite after fixes
|
| 55 |
-
|
| 56 |
-
### ❓ Potential Issues to Investigate
|
| 57 |
-
|
| 58 |
-
- [ ] **Frontend Components** - Verify these components work correctly:
|
| 59 |
-
- `src/app/dashboard/page.tsx` - Uses `conv.studentName` and `conv.coachName` (lines 224, 499, 521, 525, 527)
|
| 60 |
-
- `src/app/conversation/[id]/page.tsx` - Uses `conversation.studentName` and `conversation.coachName` (lines 318, 321, 500, 670)
|
| 61 |
-
- **Status**: Should work fine since API responses include derived names via spread operator
|
| 62 |
-
- **Action**: Test manually by creating conversations and checking UI displays correct names
|
| 63 |
-
|
| 64 |
-
- [ ] **API Type Definitions** - Check if `src/lib/types/api.ts` needs updates:
|
| 65 |
-
- **Action**: Review ConversationResponse types to ensure they match actual API responses
|
| 66 |
-
- **Note**: API responses include `studentName` and `coachName` via spread operator, so type definitions might be okay
|
| 67 |
-
|
| 68 |
-
- [ ] **E2E Test Fixes** - If tests fail, check for:
|
| 69 |
-
- Tests that directly query database for `studentName`/`coachName`
|
| 70 |
-
- Tests that create conversations with old API format
|
| 71 |
-
- Tests using deprecated `adhd_inattentive` personality ID
|
| 72 |
-
- **Action**: Search codebase for any remaining references:
|
| 73 |
-
```bash
|
| 74 |
-
grep -r "adhd_inattentive" tests/
|
| 75 |
-
grep -r "student_name\|studentName" tests/ --include="*.ts"
|
| 76 |
-
grep -r "coach_name\|coachName" tests/ --include="*.ts"
|
| 77 |
-
```
|
| 78 |
-
|
| 79 |
-
### 📝 Documentation Updates Needed
|
| 80 |
-
|
| 81 |
-
- [ ] **Update API Documentation** (`docs/backend-doc/`)
|
| 82 |
-
- [ ] Review all docs to ensure no references to storing studentName/coachName
|
| 83 |
-
- [ ] Add note explaining names are derived from templates
|
| 84 |
-
- [ ] Update example responses to show names are included but not stored
|
| 85 |
-
|
| 86 |
-
- [ ] **Update README.md**
|
| 87 |
-
- [ ] Document the template-based naming system
|
| 88 |
-
- [ ] Explain that names come from `student-prompts.ts` and `coach-prompts.ts`
|
| 89 |
-
|
| 90 |
-
- [ ] **Update CLAUDE.md**
|
| 91 |
-
- [ ] Update "Student Template Management" section if needed
|
| 92 |
-
- [ ] Add note about derived names in API responses
|
| 93 |
-
|
| 94 |
-
- [ ] **OpenAPI Spec** (`docs/openapi.yaml` and `public/docs/openapi.yaml`)
|
| 95 |
-
- [ ] Update Conversation schema to mark studentName/coachName as "derived" in description
|
| 96 |
-
- [ ] Clarify they're not stored in database but returned in API responses
|
| 97 |
-
|
| 98 |
-
## Testing Checklist
|
| 99 |
-
|
| 100 |
-
### Backend API Testing
|
| 101 |
-
|
| 102 |
-
Run verification script:
|
| 103 |
-
```bash
|
| 104 |
-
./scripts/verify-backend.sh
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
Expected results:
|
| 108 |
-
- ✅ All endpoints return 200/201/400 as expected
|
| 109 |
-
- ✅ Created conversations include `studentName` and `coachName` in response
|
| 110 |
-
- ✅ Conversation list shows correct names for each conversation
|
| 111 |
-
- ✅ Stats endpoint shows correct student names
|
| 112 |
-
|
| 113 |
-
### Manual UI Testing
|
| 114 |
-
|
| 115 |
-
1. **Create New Conversation**
|
| 116 |
-
- Navigate to dashboard
|
| 117 |
-
- Click "新對話" (New Conversation)
|
| 118 |
-
- Select a student personality (e.g., 小三學生)
|
| 119 |
-
- Select a coach (e.g., 陳老師)
|
| 120 |
-
- Verify conversation card shows correct student and coach names
|
| 121 |
-
|
| 122 |
-
2. **View Conversation**
|
| 123 |
-
- Open a conversation
|
| 124 |
-
- Verify header shows correct student and coach names
|
| 125 |
-
- Verify messages show correct speaker names/avatars
|
| 126 |
-
|
| 127 |
-
3. **Branch Conversation**
|
| 128 |
-
- Open a conversation with messages
|
| 129 |
-
- Click branch button on a message
|
| 130 |
-
- Verify branched conversation shows correct names
|
| 131 |
-
|
| 132 |
-
4. **Coach Direct Chat**
|
| 133 |
-
- Navigate to "諮詢教練" page
|
| 134 |
-
- Create coach-direct conversation
|
| 135 |
-
- Verify it shows "直接教練對話" as student name
|
| 136 |
-
- Verify it shows correct coach name
|
| 137 |
-
|
| 138 |
-
### E2E Test Suite
|
| 139 |
-
|
| 140 |
-
Run full test suite:
|
| 141 |
-
```bash
|
| 142 |
-
npm run test:e2e
|
| 143 |
-
```
|
| 144 |
-
|
| 145 |
-
Expected: All 105 tests pass
|
| 146 |
-
|
| 147 |
-
If tests fail:
|
| 148 |
-
1. Check error messages for database schema mismatches
|
| 149 |
-
2. Look for tests expecting studentName/coachName in database
|
| 150 |
-
3. Fix tests to expect derived names in API responses instead
|
| 151 |
-
|
| 152 |
-
## Rollback Plan (If Needed)
|
| 153 |
-
|
| 154 |
-
If critical issues are found:
|
| 155 |
-
|
| 156 |
-
1. **Revert Database Schema**
|
| 157 |
-
```sql
|
| 158 |
-
ALTER TABLE conversations ADD COLUMN student_name TEXT NOT NULL DEFAULT '';
|
| 159 |
-
ALTER TABLE conversations ADD COLUMN coach_name TEXT NOT NULL DEFAULT '';
|
| 160 |
-
```
|
| 161 |
-
|
| 162 |
-
2. **Revert TypeScript Models** - Add fields back to `Conversation` interface
|
| 163 |
-
|
| 164 |
-
3. **Revert Repository Methods** - Add name parameters back to `createConversation()`
|
| 165 |
-
|
| 166 |
-
4. **Revert API Routes** - Remove derivation logic, use stored names
|
| 167 |
-
|
| 168 |
-
5. **Git Revert**
|
| 169 |
-
```bash
|
| 170 |
-
git log --oneline # Find commit hash
|
| 171 |
-
git revert <commit-hash>
|
| 172 |
-
```
|
| 173 |
-
|
| 174 |
-
## Success Criteria
|
| 175 |
-
|
| 176 |
-
The implementation is complete when:
|
| 177 |
-
|
| 178 |
-
- ✅ Backend verification script passes 100%
|
| 179 |
-
- ✅ All E2E tests pass (105/105)
|
| 180 |
-
- ✅ Manual UI testing shows correct names everywhere
|
| 181 |
-
- ✅ No database references to student_name or coach_name remain
|
| 182 |
-
- ✅ Documentation is updated
|
| 183 |
-
- ✅ Code review confirms all API responses include derived names
|
| 184 |
-
|
| 185 |
-
## Notes
|
| 186 |
-
|
| 187 |
-
- **Database Migration**: No data migration needed - just delete old database and let it recreate
|
| 188 |
-
- **API Compatibility**: API responses still include `studentName` and `coachName`, so frontend doesn't need changes
|
| 189 |
-
- **Template Removal**: System gracefully handles removed templates by using stored `systemPrompt` field
|
| 190 |
-
- **Performance**: Deriving names on-the-fly has negligible performance impact (simple object lookup)
|
| 191 |
-
|
| 192 |
-
## Related Files
|
| 193 |
-
|
| 194 |
-
**Modified Files:**
|
| 195 |
-
- `src/lib/db/schema.sql`
|
| 196 |
-
- `src/lib/types/models.ts`
|
| 197 |
-
- `src/lib/repositories/conversation-repository.ts`
|
| 198 |
-
- `src/app/api/conversations/create/route.ts`
|
| 199 |
-
- `src/app/api/conversations/[conversationId]/branch/route.ts`
|
| 200 |
-
- `src/app/api/conversations/route.ts`
|
| 201 |
-
- `src/app/api/conversations/[conversationId]/route.ts`
|
| 202 |
-
- `src/app/api/stats/route.ts`
|
| 203 |
-
- `src/app/api/conversations/[conversationId]/generate-coach-summary/route.ts`
|
| 204 |
-
- `scripts/verify-backend.sh`
|
| 205 |
-
|
| 206 |
-
**Files Using Derived Names (Frontend):**
|
| 207 |
-
- `src/app/dashboard/page.tsx`
|
| 208 |
-
- `src/app/conversation/[id]/page.tsx`
|
| 209 |
-
|
| 210 |
-
**Source of Truth:**
|
| 211 |
-
- `src/lib/prompts/student-prompts.ts` - Student names and descriptions
|
| 212 |
-
- `src/lib/prompts/coach-prompts.ts` - Coach names and descriptions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/PROMPT_MANAGEMENT_DESIGN.md
CHANGED
|
@@ -959,8 +959,8 @@ If critical issues arise during deployment:
|
|
| 959 |
### Next Steps for Deployment
|
| 960 |
|
| 961 |
1. Restart development server
|
| 962 |
-
2. Run
|
| 963 |
-
3. Verify all
|
| 964 |
4. Build Docker image: `docker build -t sel-chat-coach .`
|
| 965 |
5. Test Docker container locally
|
| 966 |
6. Deploy to staging
|
|
|
|
| 959 |
### Next Steps for Deployment
|
| 960 |
|
| 961 |
1. Restart development server
|
| 962 |
+
2. Run API tests: `npm run test:api`
|
| 963 |
+
3. Verify all tests pass
|
| 964 |
4. Build Docker image: `docker build -t sel-chat-coach .`
|
| 965 |
5. Test Docker container locally
|
| 966 |
6. Deploy to staging
|
docs/backend-doc/12-database-integration.md
CHANGED
|
@@ -1113,7 +1113,7 @@ rm -f ./data/app.db # SQLite
|
|
| 1113 |
# Or truncate tables in Supabase
|
| 1114 |
|
| 1115 |
# Run tests
|
| 1116 |
-
npm run test:
|
| 1117 |
```
|
| 1118 |
|
| 1119 |
---
|
|
@@ -1317,10 +1317,10 @@ npm run dev
|
|
| 1317 |
2. **Test with both providers before deploying:**
|
| 1318 |
```bash
|
| 1319 |
# Test SQLite
|
| 1320 |
-
DATABASE_PROVIDER=sqlite npm run test:
|
| 1321 |
|
| 1322 |
# Test Supabase (separate test project)
|
| 1323 |
-
DATABASE_PROVIDER=supabase npm run test:
|
| 1324 |
```
|
| 1325 |
|
| 1326 |
3. **Use Supabase for production:**
|
|
|
|
| 1113 |
# Or truncate tables in Supabase
|
| 1114 |
|
| 1115 |
# Run tests
|
| 1116 |
+
npm run test:api
|
| 1117 |
```
|
| 1118 |
|
| 1119 |
---
|
|
|
|
| 1317 |
2. **Test with both providers before deploying:**
|
| 1318 |
```bash
|
| 1319 |
# Test SQLite
|
| 1320 |
+
DATABASE_PROVIDER=sqlite npm run test:api
|
| 1321 |
|
| 1322 |
# Test Supabase (separate test project)
|
| 1323 |
+
DATABASE_PROVIDER=supabase npm run test:api
|
| 1324 |
```
|
| 1325 |
|
| 1326 |
3. **Use Supabase for production:**
|
package.json
CHANGED
|
@@ -9,10 +9,6 @@
|
|
| 9 |
"lint": "eslint",
|
| 10 |
"db:seed": "tsx src/scripts/seed-prompts.ts",
|
| 11 |
"test:api": "playwright test --project=api",
|
| 12 |
-
"test:ui": "playwright test --project=ui",
|
| 13 |
-
"test:e2e": "playwright test",
|
| 14 |
-
"test:e2e:headed": "playwright test --headed",
|
| 15 |
-
"test:e2e:ui": "playwright test --ui",
|
| 16 |
"stress-test": "tsx scripts/stress-test/index.ts"
|
| 17 |
},
|
| 18 |
"dependencies": {
|
|
|
|
| 9 |
"lint": "eslint",
|
| 10 |
"db:seed": "tsx src/scripts/seed-prompts.ts",
|
| 11 |
"test:api": "playwright test --project=api",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"stress-test": "tsx scripts/stress-test/index.ts"
|
| 13 |
},
|
| 14 |
"dependencies": {
|
playwright.config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { defineConfig
|
| 2 |
import * as dotenv from 'dotenv';
|
| 3 |
import * as path from 'path';
|
| 4 |
|
|
@@ -27,13 +27,6 @@ export default defineConfig({
|
|
| 27 |
// API tests only use request fixture - no browser needed
|
| 28 |
// This makes them much faster to run
|
| 29 |
},
|
| 30 |
-
{
|
| 31 |
-
name: 'ui',
|
| 32 |
-
testMatch: /.*\.(spec|test)\.ts$/,
|
| 33 |
-
testIgnore: [/.*\.api\.spec\.ts$/, /.*\.setup\.ts$/],
|
| 34 |
-
dependencies: ['setup'], // Wait for setup to complete
|
| 35 |
-
use: { ...devices['Desktop Chrome'] },
|
| 36 |
-
},
|
| 37 |
],
|
| 38 |
webServer: {
|
| 39 |
command: `PORT=3000 NEXT_TELEMETRY_DISABLED=1 npm run db:seed && PORT=3000 NEXT_TELEMETRY_DISABLED=1 npm run build && PORT=3000 NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 npm start`,
|
|
|
|
| 1 |
+
import { defineConfig } from '@playwright/test';
|
| 2 |
import * as dotenv from 'dotenv';
|
| 3 |
import * as path from 'path';
|
| 4 |
|
|
|
|
| 27 |
// API tests only use request fixture - no browser needed
|
| 28 |
// This makes them much faster to run
|
| 29 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
],
|
| 31 |
webServer: {
|
| 32 |
command: `PORT=3000 NEXT_TELEMETRY_DISABLED=1 npm run db:seed && PORT=3000 NEXT_TELEMETRY_DISABLED=1 npm run build && PORT=3000 NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 npm start`,
|
tests/e2e/{admin-conversation-export-concurrent.spec.ts → admin-conversation-export-concurrent.api.spec.ts}
RENAMED
|
File without changes
|
tests/e2e/admin-conversation-export-ui.spec.ts
DELETED
|
@@ -1,365 +0,0 @@
|
|
| 1 |
-
import { test, expect, Page } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const BASE_URL = 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('Admin Conversation TSV Export UI', () => {
|
| 6 |
-
let testConversationId: string;
|
| 7 |
-
let testUsername: string;
|
| 8 |
-
|
| 9 |
-
test.beforeAll(async ({ request }) => {
|
| 10 |
-
// Create test user and conversation
|
| 11 |
-
testUsername = `exportuitest_${Date.now()}`;
|
| 12 |
-
const registerResponse = await request.post(`${BASE_URL}/api/auth/register`, {
|
| 13 |
-
data: { username: testUsername },
|
| 14 |
-
});
|
| 15 |
-
const userData = await registerResponse.json();
|
| 16 |
-
|
| 17 |
-
// Create conversation with messages
|
| 18 |
-
const password = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 19 |
-
const authHeader = `Basic ${Buffer.from(`${testUsername}:${password}`).toString('base64')}`;
|
| 20 |
-
|
| 21 |
-
const convResponse = await request.post(`${BASE_URL}/api/conversations/create`, {
|
| 22 |
-
headers: { Authorization: authHeader },
|
| 23 |
-
data: {
|
| 24 |
-
studentPromptId: 'ruirui',
|
| 25 |
-
coachPromptId: 'satir',
|
| 26 |
-
studentDescription: 'Test conversation for export UI',
|
| 27 |
-
},
|
| 28 |
-
});
|
| 29 |
-
const convData = await convResponse.json();
|
| 30 |
-
testConversationId = convData.conversation.id;
|
| 31 |
-
|
| 32 |
-
// Send test messages
|
| 33 |
-
await request.post(`${BASE_URL}/api/conversations/${testConversationId}/message`, {
|
| 34 |
-
headers: { Authorization: authHeader },
|
| 35 |
-
data: {
|
| 36 |
-
message: 'Test message for export',
|
| 37 |
-
},
|
| 38 |
-
});
|
| 39 |
-
});
|
| 40 |
-
|
| 41 |
-
test.describe('Desktop View', () => {
|
| 42 |
-
test.beforeEach(async ({ page }) => {
|
| 43 |
-
await page.setViewportSize({ width: 1280, height: 720 });
|
| 44 |
-
await page.goto('/admin/conversations');
|
| 45 |
-
await page.waitForSelector('table', { timeout: 10000 });
|
| 46 |
-
});
|
| 47 |
-
|
| 48 |
-
test('should show Download link next to View Messages in table', async ({ page }) => {
|
| 49 |
-
// Wait for table to load
|
| 50 |
-
await page.waitForSelector('a:has-text("View Messages")', { timeout: 10000 });
|
| 51 |
-
|
| 52 |
-
// Check that Download links exist in the same row as View Messages
|
| 53 |
-
const downloadLinks = page.locator('a:has-text("Download")');
|
| 54 |
-
const downloadCount = await downloadLinks.count();
|
| 55 |
-
|
| 56 |
-
expect(downloadCount).toBeGreaterThan(0);
|
| 57 |
-
|
| 58 |
-
// Verify first Download link is visible and has correct styling
|
| 59 |
-
const firstDownloadLink = downloadLinks.first();
|
| 60 |
-
await expect(firstDownloadLink).toBeVisible();
|
| 61 |
-
|
| 62 |
-
// Check that it has the green color class
|
| 63 |
-
const className = await firstDownloadLink.getAttribute('class');
|
| 64 |
-
expect(className).toContain('text-green-600');
|
| 65 |
-
});
|
| 66 |
-
|
| 67 |
-
test('should have Download links with correct href pointing to export API', async ({ page }) => {
|
| 68 |
-
await page.waitForSelector('a:has-text("Download")', { timeout: 10000 });
|
| 69 |
-
|
| 70 |
-
const downloadLinks = page.locator('a:has-text("Download")');
|
| 71 |
-
const firstLink = downloadLinks.first();
|
| 72 |
-
|
| 73 |
-
// Verify href points to export TSV endpoint
|
| 74 |
-
const href = await firstLink.getAttribute('href');
|
| 75 |
-
expect(href).toContain('/api/admin/conversations/');
|
| 76 |
-
expect(href).toContain('/export/tsv');
|
| 77 |
-
});
|
| 78 |
-
|
| 79 |
-
test('should have download attribute on Download links', async ({ page }) => {
|
| 80 |
-
await page.waitForSelector('a:has-text("Download")', { timeout: 10000 });
|
| 81 |
-
|
| 82 |
-
const downloadLinks = page.locator('a:has-text("Download")');
|
| 83 |
-
const firstLink = downloadLinks.first();
|
| 84 |
-
|
| 85 |
-
// Verify download attribute exists (triggers download instead of navigation)
|
| 86 |
-
const hasDownloadAttr = await firstLink.evaluate(el => el.hasAttribute('download'));
|
| 87 |
-
expect(hasDownloadAttr).toBe(true);
|
| 88 |
-
});
|
| 89 |
-
|
| 90 |
-
test('should show Download and View Messages links side by side', async ({ page }) => {
|
| 91 |
-
await page.waitForSelector('a:has-text("View Messages")', { timeout: 10000 });
|
| 92 |
-
|
| 93 |
-
// Get first row's action cell
|
| 94 |
-
const firstRowActionCell = page.locator('td').filter({ has: page.locator('a:has-text("View Messages")') }).first();
|
| 95 |
-
|
| 96 |
-
// Should contain both links
|
| 97 |
-
await expect(firstRowActionCell.locator('a:has-text("View Messages")')).toBeVisible();
|
| 98 |
-
await expect(firstRowActionCell.locator('a:has-text("Download")')).toBeVisible();
|
| 99 |
-
|
| 100 |
-
// Should be in a flex container
|
| 101 |
-
const flexContainer = firstRowActionCell.locator('div.flex');
|
| 102 |
-
await expect(flexContainer).toBeVisible();
|
| 103 |
-
});
|
| 104 |
-
|
| 105 |
-
test('should maintain table layout with Download link added', async ({ page }) => {
|
| 106 |
-
// Verify table headers are still correct
|
| 107 |
-
await expect(page.locator('th:has-text("Title")')).toBeVisible();
|
| 108 |
-
await expect(page.locator('th:has-text("User")')).toBeVisible();
|
| 109 |
-
await expect(page.locator('th:has-text("Student")')).toBeVisible();
|
| 110 |
-
await expect(page.locator('th:has-text("Coach")')).toBeVisible();
|
| 111 |
-
await expect(page.locator('th:has-text("Messages")')).toBeVisible();
|
| 112 |
-
await expect(page.locator('th:has-text("Last Active")')).toBeVisible();
|
| 113 |
-
await expect(page.locator('th:has-text("Actions")')).toBeVisible();
|
| 114 |
-
|
| 115 |
-
// Verify each row has both action links
|
| 116 |
-
const rows = page.locator('tbody tr');
|
| 117 |
-
const rowCount = await rows.count();
|
| 118 |
-
|
| 119 |
-
if (rowCount > 0) {
|
| 120 |
-
// Check first few rows
|
| 121 |
-
for (let i = 0; i < Math.min(rowCount, 3); i++) {
|
| 122 |
-
const row = rows.nth(i);
|
| 123 |
-
await expect(row.locator('a:has-text("View Messages")')).toBeVisible();
|
| 124 |
-
await expect(row.locator('a:has-text("Download")')).toBeVisible();
|
| 125 |
-
}
|
| 126 |
-
}
|
| 127 |
-
});
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
-
test.describe('Mobile View', () => {
|
| 131 |
-
test.beforeEach(async ({ page }) => {
|
| 132 |
-
await page.setViewportSize({ width: 375, height: 667 });
|
| 133 |
-
await page.goto('/admin/conversations');
|
| 134 |
-
// Wait for mobile-specific content (the mobile card layout has Student/Coach labels that desktop table doesn't)
|
| 135 |
-
await page.waitForSelector('text=Student:', { timeout: 10000 });
|
| 136 |
-
});
|
| 137 |
-
|
| 138 |
-
test('should show Download button in mobile card view', async ({ page }) => {
|
| 139 |
-
// Wait for mobile cards to load (specifically looking for mobile layout structure)
|
| 140 |
-
await expect(page.locator('text=Student:').first()).toBeVisible();
|
| 141 |
-
|
| 142 |
-
// Get the mobile card container specifically (md:hidden means mobile-only)
|
| 143 |
-
const mobileContainer = page.locator('div.md\\:hidden');
|
| 144 |
-
|
| 145 |
-
// Check that Download buttons exist within mobile cards
|
| 146 |
-
const downloadButtons = mobileContainer.locator('a:has-text("Download")');
|
| 147 |
-
const downloadCount = await downloadButtons.count();
|
| 148 |
-
|
| 149 |
-
expect(downloadCount).toBeGreaterThan(0);
|
| 150 |
-
|
| 151 |
-
// Verify first Download button is visible
|
| 152 |
-
await expect(downloadButtons.first()).toBeVisible();
|
| 153 |
-
});
|
| 154 |
-
|
| 155 |
-
test('should have Download button with green styling in mobile view', async ({ page }) => {
|
| 156 |
-
await expect(page.locator('text=Student:').first()).toBeVisible();
|
| 157 |
-
|
| 158 |
-
// Get mobile container and find Download button within it
|
| 159 |
-
const mobileContainer = page.locator('div.md\\:hidden');
|
| 160 |
-
const downloadButton = mobileContainer.locator('a:has-text("Download")').first();
|
| 161 |
-
|
| 162 |
-
// Wait for it to be visible
|
| 163 |
-
await expect(downloadButton).toBeVisible();
|
| 164 |
-
|
| 165 |
-
// Check that it has the green background class
|
| 166 |
-
const className = await downloadButton.getAttribute('class');
|
| 167 |
-
expect(className).toContain('bg-green-600');
|
| 168 |
-
expect(className).toContain('text-white');
|
| 169 |
-
});
|
| 170 |
-
|
| 171 |
-
test('should show Download and View Messages buttons side by side in mobile cards', async ({ page }) => {
|
| 172 |
-
await expect(page.locator('text=Student:').first()).toBeVisible();
|
| 173 |
-
|
| 174 |
-
// Get mobile container and first card within it
|
| 175 |
-
const mobileContainer = page.locator('div.md\\:hidden');
|
| 176 |
-
const firstCard = mobileContainer.locator('.bg-white.rounded-lg.shadow').first();
|
| 177 |
-
|
| 178 |
-
// Should contain both buttons
|
| 179 |
-
await expect(firstCard.locator('a:has-text("View Messages")')).toBeVisible();
|
| 180 |
-
await expect(firstCard.locator('a:has-text("Download")')).toBeVisible();
|
| 181 |
-
|
| 182 |
-
// Should be in a flex container
|
| 183 |
-
const flexContainer = firstCard.locator('div.flex.gap-2').last();
|
| 184 |
-
await expect(flexContainer).toBeVisible();
|
| 185 |
-
});
|
| 186 |
-
|
| 187 |
-
test('should have equal width buttons in mobile view', async ({ page }) => {
|
| 188 |
-
await expect(page.locator('text=Student:').first()).toBeVisible();
|
| 189 |
-
|
| 190 |
-
// Get mobile container and first card within it
|
| 191 |
-
const mobileContainer = page.locator('div.md\\:hidden');
|
| 192 |
-
const firstCard = mobileContainer.locator('.bg-white.rounded-lg.shadow').first();
|
| 193 |
-
const viewButton = firstCard.locator('a:has-text("View Messages")');
|
| 194 |
-
const downloadButton = firstCard.locator('a:has-text("Download")');
|
| 195 |
-
|
| 196 |
-
// Both should have flex-1 class for equal width
|
| 197 |
-
const viewClass = await viewButton.getAttribute('class');
|
| 198 |
-
const downloadClass = await downloadButton.getAttribute('class');
|
| 199 |
-
|
| 200 |
-
expect(viewClass).toContain('flex-1');
|
| 201 |
-
expect(downloadClass).toContain('flex-1');
|
| 202 |
-
});
|
| 203 |
-
});
|
| 204 |
-
|
| 205 |
-
test.describe('Download Functionality', () => {
|
| 206 |
-
test.beforeEach(async ({ page }) => {
|
| 207 |
-
await page.goto('/admin/conversations');
|
| 208 |
-
await page.waitForSelector('a:has-text("Download")', { timeout: 10000 });
|
| 209 |
-
});
|
| 210 |
-
|
| 211 |
-
test('should trigger download when Download link is clicked', async ({ page, context }) => {
|
| 212 |
-
// Listen for download event
|
| 213 |
-
const downloadPromise = page.waitForEvent('download');
|
| 214 |
-
|
| 215 |
-
// Find and click the Download link for our test conversation
|
| 216 |
-
const downloadLinks = page.locator('a:has-text("Download")');
|
| 217 |
-
const linkCount = await downloadLinks.count();
|
| 218 |
-
|
| 219 |
-
// Click the first download link (any conversation will do for this test)
|
| 220 |
-
if (linkCount > 0) {
|
| 221 |
-
await downloadLinks.first().click();
|
| 222 |
-
|
| 223 |
-
// Wait for download to start
|
| 224 |
-
const download = await downloadPromise;
|
| 225 |
-
|
| 226 |
-
// Verify download
|
| 227 |
-
expect(download).toBeTruthy();
|
| 228 |
-
|
| 229 |
-
// Verify filename format
|
| 230 |
-
const filename = download.suggestedFilename();
|
| 231 |
-
expect(filename).toMatch(/^conversation_.*\.tsv$/);
|
| 232 |
-
expect(filename).toContain('.tsv');
|
| 233 |
-
}
|
| 234 |
-
});
|
| 235 |
-
|
| 236 |
-
test('should download valid TSV file', async ({ page, context }) => {
|
| 237 |
-
// Find download link for our specific test conversation
|
| 238 |
-
const downloadLink = page.locator(`a[href*="${testConversationId}"][href*="export/tsv"]`).first();
|
| 239 |
-
|
| 240 |
-
if (await downloadLink.count() > 0) {
|
| 241 |
-
// Wait for download
|
| 242 |
-
const downloadPromise = page.waitForEvent('download');
|
| 243 |
-
await downloadLink.click();
|
| 244 |
-
const download = await downloadPromise;
|
| 245 |
-
|
| 246 |
-
// Save the download to verify content
|
| 247 |
-
const path = await download.path();
|
| 248 |
-
expect(path).toBeTruthy();
|
| 249 |
-
|
| 250 |
-
// Read and verify TSV structure (basic check)
|
| 251 |
-
if (path) {
|
| 252 |
-
const fs = require('fs');
|
| 253 |
-
const content = fs.readFileSync(path, 'utf-8');
|
| 254 |
-
|
| 255 |
-
// Should have header row
|
| 256 |
-
expect(content).toContain('Conversation Title');
|
| 257 |
-
expect(content).toContain('Username');
|
| 258 |
-
expect(content).toContain('Student Name');
|
| 259 |
-
expect(content).toContain('Coach Name');
|
| 260 |
-
expect(content).toContain('Message Role');
|
| 261 |
-
expect(content).toContain('Speaker');
|
| 262 |
-
expect(content).toContain('Content');
|
| 263 |
-
expect(content).toContain('Timestamp');
|
| 264 |
-
|
| 265 |
-
// Should have data rows (at least one message)
|
| 266 |
-
const lines = content.split('\n');
|
| 267 |
-
expect(lines.length).toBeGreaterThan(1);
|
| 268 |
-
}
|
| 269 |
-
} else {
|
| 270 |
-
// Test conversation not visible on current page, that's OK
|
| 271 |
-
console.log('Test conversation not visible on current page, skipping download verification');
|
| 272 |
-
}
|
| 273 |
-
});
|
| 274 |
-
});
|
| 275 |
-
|
| 276 |
-
test.describe('Accessibility', () => {
|
| 277 |
-
test.beforeEach(async ({ page }) => {
|
| 278 |
-
await page.goto('/admin/conversations');
|
| 279 |
-
await page.waitForSelector('a:has-text("Download")', { timeout: 10000 });
|
| 280 |
-
});
|
| 281 |
-
|
| 282 |
-
test('should have proper link semantics', async ({ page }) => {
|
| 283 |
-
const downloadLink = page.locator('a:has-text("Download")').first();
|
| 284 |
-
|
| 285 |
-
// Should be an anchor element
|
| 286 |
-
const tagName = await downloadLink.evaluate(el => el.tagName);
|
| 287 |
-
expect(tagName).toBe('A');
|
| 288 |
-
|
| 289 |
-
// Should have href attribute
|
| 290 |
-
const href = await downloadLink.getAttribute('href');
|
| 291 |
-
expect(href).toBeTruthy();
|
| 292 |
-
});
|
| 293 |
-
|
| 294 |
-
test('should be keyboard accessible', async ({ page }) => {
|
| 295 |
-
// Tab to a Download link
|
| 296 |
-
await page.keyboard.press('Tab');
|
| 297 |
-
|
| 298 |
-
// Keep tabbing until we find a Download link with focus
|
| 299 |
-
let attempts = 0;
|
| 300 |
-
let foundDownloadLink = false;
|
| 301 |
-
|
| 302 |
-
while (attempts < 50 && !foundDownloadLink) {
|
| 303 |
-
const focusedElement = await page.evaluate(() => {
|
| 304 |
-
const el = document.activeElement;
|
| 305 |
-
return el ? el.textContent : '';
|
| 306 |
-
});
|
| 307 |
-
|
| 308 |
-
if (focusedElement?.includes('Download')) {
|
| 309 |
-
foundDownloadLink = true;
|
| 310 |
-
break;
|
| 311 |
-
}
|
| 312 |
-
|
| 313 |
-
await page.keyboard.press('Tab');
|
| 314 |
-
attempts++;
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
// If we found a Download link, verify it can be activated with Enter
|
| 318 |
-
if (foundDownloadLink) {
|
| 319 |
-
// Verify focused element is a Download link
|
| 320 |
-
const focusedElement = await page.evaluate(() => {
|
| 321 |
-
const el = document.activeElement;
|
| 322 |
-
return {
|
| 323 |
-
text: el?.textContent || '',
|
| 324 |
-
href: (el as HTMLAnchorElement)?.href || '',
|
| 325 |
-
};
|
| 326 |
-
});
|
| 327 |
-
|
| 328 |
-
expect(focusedElement.text).toContain('Download');
|
| 329 |
-
expect(focusedElement.href).toContain('/export/tsv');
|
| 330 |
-
}
|
| 331 |
-
});
|
| 332 |
-
});
|
| 333 |
-
|
| 334 |
-
test.describe('Visual Consistency', () => {
|
| 335 |
-
test.beforeEach(async ({ page }) => {
|
| 336 |
-
await page.goto('/admin/conversations');
|
| 337 |
-
await page.waitForSelector('a:has-text("Download")', { timeout: 10000 });
|
| 338 |
-
});
|
| 339 |
-
|
| 340 |
-
test('should have consistent color scheme', async ({ page }) => {
|
| 341 |
-
const downloadLink = page.locator('a:has-text("Download")').first();
|
| 342 |
-
const viewLink = page.locator('a:has-text("View Messages")').first();
|
| 343 |
-
|
| 344 |
-
// Download should be green
|
| 345 |
-
const downloadClass = await downloadLink.getAttribute('class');
|
| 346 |
-
expect(downloadClass).toMatch(/text-green-600|bg-green-600/);
|
| 347 |
-
|
| 348 |
-
// View Messages should be blue
|
| 349 |
-
const viewClass = await viewLink.getAttribute('class');
|
| 350 |
-
expect(viewClass).toMatch(/text-blue-600|bg-blue-600/);
|
| 351 |
-
});
|
| 352 |
-
|
| 353 |
-
test('should have proper spacing between links', async ({ page }) => {
|
| 354 |
-
await page.setViewportSize({ width: 1280, height: 720 });
|
| 355 |
-
|
| 356 |
-
// Get action cell with both links
|
| 357 |
-
const actionCell = page.locator('td').filter({ has: page.locator('a:has-text("Download")') }).first();
|
| 358 |
-
const flexContainer = actionCell.locator('div.flex');
|
| 359 |
-
|
| 360 |
-
// Should have gap class
|
| 361 |
-
const className = await flexContainer.getAttribute('class');
|
| 362 |
-
expect(className).toContain('gap');
|
| 363 |
-
});
|
| 364 |
-
});
|
| 365 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/{admin-conversation-export.spec.ts → admin-conversation-export.api.spec.ts}
RENAMED
|
@@ -482,19 +482,26 @@ test.describe('Admin Conversation TSV Export API', () => {
|
|
| 482 |
};
|
| 483 |
|
| 484 |
// Parse TSV into structured data
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
|
| 499 |
// Expected structure:
|
| 500 |
// Row 0: System message (role: system, speaker: null or empty)
|
|
|
|
| 482 |
};
|
| 483 |
|
| 484 |
// Parse TSV into structured data
|
| 485 |
+
// Note: Using || '' to prevent undefined errors when split('\n') breaks multi-line quoted fields
|
| 486 |
+
const rows = lines.slice(1)
|
| 487 |
+
.map(line => {
|
| 488 |
+
const fields = parseTsvLine(line);
|
| 489 |
+
// Filter out partial rows from multi-line content (they have < 5 fields)
|
| 490 |
+
if (fields.length < 5) {
|
| 491 |
+
return null;
|
| 492 |
+
}
|
| 493 |
+
return {
|
| 494 |
+
conversationTitle: fields[0] || '',
|
| 495 |
+
username: fields[1] || '',
|
| 496 |
+
studentName: fields[2] || '',
|
| 497 |
+
coachName: fields[3] || '',
|
| 498 |
+
messageRole: fields[4] || '',
|
| 499 |
+
speaker: fields[5] || '',
|
| 500 |
+
content: fields[6] || '',
|
| 501 |
+
timestamp: fields[7] || '',
|
| 502 |
+
};
|
| 503 |
+
})
|
| 504 |
+
.filter(row => row !== null); // Remove null rows (partial lines)
|
| 505 |
|
| 506 |
// Expected structure:
|
| 507 |
// Row 0: System message (role: system, speaker: null or empty)
|
tests/e2e/admin-conversation-speaker-display.spec.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const BASE_URL = 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('Admin Conversation Detail Page - Speaker Display', () => {
|
| 6 |
-
let testUserId: string;
|
| 7 |
-
let testUsername: string;
|
| 8 |
-
let testConversationId: string;
|
| 9 |
-
const testPassword = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 10 |
-
|
| 11 |
-
test.beforeAll(async ({ request }) => {
|
| 12 |
-
// Create a test user
|
| 13 |
-
const timestamp = Date.now();
|
| 14 |
-
testUsername = `speaker_display_test_${timestamp}`;
|
| 15 |
-
|
| 16 |
-
const registerResponse = await request.post(`${BASE_URL}/api/auth/register`, {
|
| 17 |
-
data: {
|
| 18 |
-
username: testUsername,
|
| 19 |
-
},
|
| 20 |
-
});
|
| 21 |
-
|
| 22 |
-
expect(registerResponse.ok()).toBeTruthy();
|
| 23 |
-
const userData = await registerResponse.json();
|
| 24 |
-
testUserId = userData.user.id;
|
| 25 |
-
|
| 26 |
-
// Create Basic Auth header
|
| 27 |
-
const authHeader = `Basic ${Buffer.from(`${testUsername}:${testPassword}`).toString('base64')}`;
|
| 28 |
-
|
| 29 |
-
// Create a test conversation with both student and coach messages
|
| 30 |
-
const createConvResponse = await request.post(`${BASE_URL}/api/conversations/create`, {
|
| 31 |
-
headers: {
|
| 32 |
-
Authorization: authHeader,
|
| 33 |
-
},
|
| 34 |
-
data: {
|
| 35 |
-
studentPromptId: 'ruirui',
|
| 36 |
-
coachPromptId: 'satir',
|
| 37 |
-
studentDescription: 'Test student for speaker display verification',
|
| 38 |
-
},
|
| 39 |
-
});
|
| 40 |
-
|
| 41 |
-
expect(createConvResponse.ok()).toBeTruthy();
|
| 42 |
-
const convData = await createConvResponse.json();
|
| 43 |
-
testConversationId = convData.conversation.id;
|
| 44 |
-
|
| 45 |
-
// Send a message to the student (should trigger student response with speaker='student')
|
| 46 |
-
const studentMsgResponse = await request.post(`${BASE_URL}/api/conversations/${testConversationId}/message`, {
|
| 47 |
-
headers: {
|
| 48 |
-
Authorization: authHeader,
|
| 49 |
-
},
|
| 50 |
-
data: {
|
| 51 |
-
messages: [
|
| 52 |
-
{ id: 'msg1', role: 'user', parts: [{ type: 'text', text: 'Hello student, how are you?' }] }
|
| 53 |
-
]
|
| 54 |
-
},
|
| 55 |
-
timeout: 90_000,
|
| 56 |
-
});
|
| 57 |
-
expect(studentMsgResponse.ok()).toBeTruthy();
|
| 58 |
-
|
| 59 |
-
// Send a message to the coach (should trigger coach response with speaker='coach')
|
| 60 |
-
const coachMsgResponse = await request.post(`${BASE_URL}/api/conversations/${testConversationId}/message`, {
|
| 61 |
-
headers: {
|
| 62 |
-
Authorization: authHeader,
|
| 63 |
-
},
|
| 64 |
-
data: {
|
| 65 |
-
messages: [
|
| 66 |
-
{
|
| 67 |
-
id: 'msg2',
|
| 68 |
-
role: 'user',
|
| 69 |
-
parts: [{ type: 'text', text: '@coach Can you help me understand this student?' }],
|
| 70 |
-
metadata: { speaker: 'coach' } // Set speaker in metadata
|
| 71 |
-
}
|
| 72 |
-
],
|
| 73 |
-
},
|
| 74 |
-
timeout: 90_000,
|
| 75 |
-
});
|
| 76 |
-
expect(coachMsgResponse.ok()).toBeTruthy();
|
| 77 |
-
});
|
| 78 |
-
|
| 79 |
-
test('Admin API should return messages with speaker field (not in metadata)', async ({ request }) => {
|
| 80 |
-
const response = await request.get(
|
| 81 |
-
`${BASE_URL}/api/admin/conversations/${testConversationId}`
|
| 82 |
-
);
|
| 83 |
-
|
| 84 |
-
expect(response.ok()).toBeTruthy();
|
| 85 |
-
const data = await response.json();
|
| 86 |
-
|
| 87 |
-
// Verify we have messages
|
| 88 |
-
expect(data.messages.length).toBeGreaterThan(0);
|
| 89 |
-
|
| 90 |
-
// Find student and coach messages
|
| 91 |
-
const studentMessages = data.messages.filter((msg: any) => msg.speaker === 'student');
|
| 92 |
-
const coachMessages = data.messages.filter((msg: any) => msg.speaker === 'coach');
|
| 93 |
-
|
| 94 |
-
// Should have at least one student message (from first exchange)
|
| 95 |
-
expect(studentMessages.length).toBeGreaterThan(0);
|
| 96 |
-
|
| 97 |
-
// Should have at least one coach message (from second exchange)
|
| 98 |
-
expect(coachMessages.length).toBeGreaterThan(0);
|
| 99 |
-
|
| 100 |
-
// Verify speaker is a direct field, not in metadata
|
| 101 |
-
for (const msg of data.messages) {
|
| 102 |
-
if (msg.role === 'assistant') {
|
| 103 |
-
// Speaker should be a direct field on the message object
|
| 104 |
-
expect(msg).toHaveProperty('speaker');
|
| 105 |
-
expect(['student', 'coach']).toContain(msg.speaker);
|
| 106 |
-
|
| 107 |
-
// Speaker should NOT be in metadata (that's for UI streaming messages)
|
| 108 |
-
expect(msg.metadata).toBeUndefined();
|
| 109 |
-
}
|
| 110 |
-
}
|
| 111 |
-
});
|
| 112 |
-
|
| 113 |
-
test('Admin page should display BOTH student and coach names based on speaker field', async ({ page }) => {
|
| 114 |
-
// Navigate to admin conversation detail page
|
| 115 |
-
await page.goto(`${BASE_URL}/admin/conversations/${testConversationId}`);
|
| 116 |
-
|
| 117 |
-
// Wait for messages to load
|
| 118 |
-
await page.waitForSelector('[class*="space-y-4"]', { timeout: 10000 });
|
| 119 |
-
|
| 120 |
-
// Get all text content from the page
|
| 121 |
-
const pageContent = await page.locator('[class*="space-y-4"]').textContent();
|
| 122 |
-
|
| 123 |
-
// Should contain BOTH student and coach names
|
| 124 |
-
expect(pageContent).toContain('睿睿');
|
| 125 |
-
expect(pageContent).toContain('薩提爾');
|
| 126 |
-
|
| 127 |
-
// Verify names appear in name labels (outside bubbles)
|
| 128 |
-
const nameLabels = await page.locator('[class*="text-xs"][class*="font-semibold"]').allTextContents();
|
| 129 |
-
|
| 130 |
-
const hasStudentName = nameLabels.some(label => label.includes('睿睿'));
|
| 131 |
-
const hasCoachName = nameLabels.some(label => label.includes('薩提爾'));
|
| 132 |
-
|
| 133 |
-
expect(hasStudentName).toBeTruthy();
|
| 134 |
-
expect(hasCoachName).toBeTruthy();
|
| 135 |
-
});
|
| 136 |
-
|
| 137 |
-
test('Admin page should NOT show name labels for user messages (right side)', async ({ page }) => {
|
| 138 |
-
// Navigate to admin conversation detail page
|
| 139 |
-
await page.goto(`${BASE_URL}/admin/conversations/${testConversationId}`);
|
| 140 |
-
|
| 141 |
-
// Wait for messages to load
|
| 142 |
-
await page.waitForSelector('[class*="space-y-4"]', { timeout: 10000 });
|
| 143 |
-
|
| 144 |
-
// Visual verification: page should have the correct structure
|
| 145 |
-
// User messages should exist but not include name labels
|
| 146 |
-
const pageContent = await page.textContent('body');
|
| 147 |
-
|
| 148 |
-
// Page should contain user messages (we sent them)
|
| 149 |
-
expect(pageContent).toContain('Hello student');
|
| 150 |
-
expect(pageContent).toContain('@coach');
|
| 151 |
-
|
| 152 |
-
// Test passes if we can visually confirm layout is correct
|
| 153 |
-
// (This is a smoke test - manual visual confirmation is primary)
|
| 154 |
-
expect(pageContent).toBeTruthy();
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
test('Admin page should show correct emojis for student vs coach messages', async ({ page }) => {
|
| 158 |
-
// Navigate to admin conversation detail page
|
| 159 |
-
await page.goto(`${BASE_URL}/admin/conversations/${testConversationId}`);
|
| 160 |
-
|
| 161 |
-
// Wait for messages to load
|
| 162 |
-
await page.waitForSelector('[class*="space-y-4"]', { timeout: 10000 });
|
| 163 |
-
|
| 164 |
-
// Get all page content
|
| 165 |
-
const pageContent = await page.textContent('body');
|
| 166 |
-
|
| 167 |
-
// Should contain both student and coach names
|
| 168 |
-
expect(pageContent).toContain('睿睿');
|
| 169 |
-
expect(pageContent).toContain('薩提爾');
|
| 170 |
-
|
| 171 |
-
// Emojis may not be captured reliably in text content
|
| 172 |
-
// Visual confirmation is primary (test passes if page loads correctly)
|
| 173 |
-
expect(pageContent).toBeTruthy();
|
| 174 |
-
});
|
| 175 |
-
|
| 176 |
-
test('Admin page should have avatar circles for assistant messages', async ({ page }) => {
|
| 177 |
-
// Navigate to admin conversation detail page
|
| 178 |
-
await page.goto(`${BASE_URL}/admin/conversations/${testConversationId}`);
|
| 179 |
-
|
| 180 |
-
// Wait for messages to load
|
| 181 |
-
await page.waitForSelector('[class*="space-y-4"]', { timeout: 10000 });
|
| 182 |
-
|
| 183 |
-
// Visual verification: page should display avatars with modern styling
|
| 184 |
-
const pageContent = await page.textContent('body');
|
| 185 |
-
|
| 186 |
-
// Should contain conversation participants
|
| 187 |
-
expect(pageContent).toContain('睿睿');
|
| 188 |
-
expect(pageContent).toContain('薩提爾');
|
| 189 |
-
|
| 190 |
-
// Avatar styling verified via manual visual review
|
| 191 |
-
// Test passes if page loads correctly with all content
|
| 192 |
-
expect(pageContent).toBeTruthy();
|
| 193 |
-
});
|
| 194 |
-
|
| 195 |
-
test('Admin page should use modern styling for message bubbles', async ({ page }) => {
|
| 196 |
-
// Navigate to admin conversation detail page
|
| 197 |
-
await page.goto(`${BASE_URL}/admin/conversations/${testConversationId}`);
|
| 198 |
-
|
| 199 |
-
// Wait for messages to load
|
| 200 |
-
await page.waitForSelector('[class*="space-y-4"]', { timeout: 10000 });
|
| 201 |
-
|
| 202 |
-
// Visual verification: page should display messages with modern styling
|
| 203 |
-
const pageContent = await page.textContent('body');
|
| 204 |
-
|
| 205 |
-
// Should contain conversation content
|
| 206 |
-
expect(pageContent).toContain('睿睿');
|
| 207 |
-
expect(pageContent).toContain('薩提爾');
|
| 208 |
-
expect(pageContent).toContain('Hello student');
|
| 209 |
-
|
| 210 |
-
// Smoke test for visual presentation
|
| 211 |
-
// Gradients and specific styling verified via manual review
|
| 212 |
-
expect(pageContent).toBeTruthy();
|
| 213 |
-
});
|
| 214 |
-
|
| 215 |
-
test('Admin page should show name labels positioned correctly', async ({ page }) => {
|
| 216 |
-
// Navigate to admin conversation detail page
|
| 217 |
-
await page.goto(`${BASE_URL}/admin/conversations/${testConversationId}`);
|
| 218 |
-
|
| 219 |
-
// Wait for messages to load
|
| 220 |
-
await page.waitForSelector('[class*="space-y-4"]', { timeout: 10000 });
|
| 221 |
-
|
| 222 |
-
// Verify page contains both student and coach names
|
| 223 |
-
const pageContent = await page.textContent('body');
|
| 224 |
-
|
| 225 |
-
expect(pageContent).toContain('睿睿');
|
| 226 |
-
expect(pageContent).toContain('薩提爾');
|
| 227 |
-
|
| 228 |
-
// Verify names appear multiple times (once in header info, multiple times in messages)
|
| 229 |
-
const studentNameCount = (pageContent?.match(/睿睿/g) || []).length;
|
| 230 |
-
const coachNameCount = (pageContent?.match(/薩提爾/g) || []).length;
|
| 231 |
-
|
| 232 |
-
// Should appear at least twice each (header + at least one message)
|
| 233 |
-
expect(studentNameCount).toBeGreaterThanOrEqual(2);
|
| 234 |
-
expect(coachNameCount).toBeGreaterThanOrEqual(2);
|
| 235 |
-
});
|
| 236 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/admin-conversations.ui.spec.ts
DELETED
|
@@ -1,336 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const BASE_URL = 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('Admin Conversations UI', () => {
|
| 6 |
-
let testConversationId: string;
|
| 7 |
-
let testUsername: string;
|
| 8 |
-
|
| 9 |
-
test.beforeAll(async ({ request }) => {
|
| 10 |
-
// Create test user and conversation for UI tests
|
| 11 |
-
testUsername = `convuitest_${Date.now()}`;
|
| 12 |
-
const registerResponse = await request.post(`${BASE_URL}/api/auth/register`, {
|
| 13 |
-
data: { username: testUsername },
|
| 14 |
-
});
|
| 15 |
-
const userData = await registerResponse.json();
|
| 16 |
-
|
| 17 |
-
// Create conversation
|
| 18 |
-
const password = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 19 |
-
const authHeader = `Basic ${Buffer.from(`${testUsername}:${password}`).toString('base64')}`;
|
| 20 |
-
|
| 21 |
-
const convResponse = await request.post(`${BASE_URL}/api/conversations/create`, {
|
| 22 |
-
headers: { Authorization: authHeader },
|
| 23 |
-
data: {
|
| 24 |
-
studentPromptId: 'ruirui',
|
| 25 |
-
coachPromptId: 'satir',
|
| 26 |
-
studentDescription: 'UI test conversation',
|
| 27 |
-
},
|
| 28 |
-
});
|
| 29 |
-
const convData = await convResponse.json();
|
| 30 |
-
testConversationId = convData.conversation.id;
|
| 31 |
-
});
|
| 32 |
-
|
| 33 |
-
test.describe('Conversations List Page', () => {
|
| 34 |
-
test.beforeEach(async ({ page }) => {
|
| 35 |
-
await page.goto('/admin/conversations');
|
| 36 |
-
});
|
| 37 |
-
|
| 38 |
-
test('should load conversations list page', async ({ page }) => {
|
| 39 |
-
await expect(page.getByRole('heading', { name: 'Conversations' })).toBeVisible();
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
test('should display conversations table on desktop', async ({ page }) => {
|
| 43 |
-
await page.setViewportSize({ width: 1280, height: 720 });
|
| 44 |
-
await page.waitForSelector('table, .space-y-4', { timeout: 10000 });
|
| 45 |
-
|
| 46 |
-
// Should show table headers
|
| 47 |
-
await expect(page.locator('th:has-text("User")')).toBeVisible();
|
| 48 |
-
await expect(page.locator('th:has-text("Title")')).toBeVisible();
|
| 49 |
-
await expect(page.locator('th:has-text("Student")')).toBeVisible();
|
| 50 |
-
await expect(page.locator('th:has-text("Coach")')).toBeVisible();
|
| 51 |
-
});
|
| 52 |
-
|
| 53 |
-
test('should display conversations cards on mobile', async ({ page }) => {
|
| 54 |
-
await page.setViewportSize({ width: 375, height: 667 });
|
| 55 |
-
await page.waitForSelector('.bg-white.rounded-lg.shadow', { timeout: 10000 });
|
| 56 |
-
|
| 57 |
-
const cards = page.locator('.bg-white.rounded-lg.shadow');
|
| 58 |
-
await expect(cards.first()).toBeVisible();
|
| 59 |
-
});
|
| 60 |
-
|
| 61 |
-
test('should have filter controls', async ({ page }) => {
|
| 62 |
-
// Should have filter options (user, student, coach)
|
| 63 |
-
await page.waitForSelector('select, input[type="search"]', { timeout: 10000 });
|
| 64 |
-
await expect(page.locator('select, input[type="search"]')).toBeVisible();
|
| 65 |
-
});
|
| 66 |
-
|
| 67 |
-
test('should show results info', async ({ page }) => {
|
| 68 |
-
// Check for the results text in the specific div
|
| 69 |
-
await page.waitForSelector('text=Showing', { timeout: 10000 });
|
| 70 |
-
const resultsDiv = page.locator('div.mt-4.text-sm.text-gray-600');
|
| 71 |
-
await expect(resultsDiv).toContainText('Showing');
|
| 72 |
-
await expect(resultsDiv).toContainText('conversations');
|
| 73 |
-
});
|
| 74 |
-
|
| 75 |
-
test('should navigate to conversation detail', async ({ page }) => {
|
| 76 |
-
await page.waitForSelector('a[href*="/admin/conversations/"]', { timeout: 10000 });
|
| 77 |
-
|
| 78 |
-
// Click first "View Messages" link
|
| 79 |
-
const firstConvLink = page.locator('a[href*="/admin/conversations/"]:has-text("View Messages")').first();
|
| 80 |
-
await firstConvLink.click();
|
| 81 |
-
|
| 82 |
-
// Should navigate to detail page
|
| 83 |
-
await page.waitForURL('**/admin/conversations/**');
|
| 84 |
-
// Page shows conversation title, just verify a heading exists
|
| 85 |
-
await expect(page.getByRole('heading').first()).toBeVisible();
|
| 86 |
-
});
|
| 87 |
-
});
|
| 88 |
-
|
| 89 |
-
test.describe('Conversation Detail Page', () => {
|
| 90 |
-
test.beforeEach(async ({ page }) => {
|
| 91 |
-
await page.goto(`/admin/conversations/${testConversationId}`);
|
| 92 |
-
});
|
| 93 |
-
|
| 94 |
-
test('should load conversation detail page', async ({ page }) => {
|
| 95 |
-
// Page shows the conversation title as h1, wait for any heading to appear
|
| 96 |
-
await expect(page.getByRole('heading').first()).toBeVisible();
|
| 97 |
-
});
|
| 98 |
-
|
| 99 |
-
test('should display conversation metadata', async ({ page }) => {
|
| 100 |
-
// Wait for conversation info to load
|
| 101 |
-
await page.waitForSelector('text=User', { timeout: 10000 });
|
| 102 |
-
|
| 103 |
-
// Should show student and coach persona labels
|
| 104 |
-
await expect(page.locator('text=Student Persona')).toBeVisible();
|
| 105 |
-
await expect(page.locator('text=Coach Persona')).toBeVisible();
|
| 106 |
-
});
|
| 107 |
-
|
| 108 |
-
test('should show conversation title', async ({ page }) => {
|
| 109 |
-
// The page shows the conversation title in the h1, not a separate ID field
|
| 110 |
-
await expect(page.getByRole('heading')).toBeVisible();
|
| 111 |
-
});
|
| 112 |
-
|
| 113 |
-
test('should display messages section', async ({ page }) => {
|
| 114 |
-
await expect(page.locator('text=Messages')).toBeVisible();
|
| 115 |
-
});
|
| 116 |
-
|
| 117 |
-
test('should have back to conversations button', async ({ page }) => {
|
| 118 |
-
const backLink = page.locator('a[href="/admin/conversations"]:has-text("Back to Conversations")');
|
| 119 |
-
await expect(backLink).toBeVisible();
|
| 120 |
-
|
| 121 |
-
await backLink.click();
|
| 122 |
-
await page.waitForURL('**/admin/conversations');
|
| 123 |
-
});
|
| 124 |
-
|
| 125 |
-
test('should show system prompt if available', async ({ page }) => {
|
| 126 |
-
// System prompt section should be visible or display "No system prompt"
|
| 127 |
-
const hasSystemPrompt = await page.locator('text=System Prompt').isVisible();
|
| 128 |
-
if (hasSystemPrompt) {
|
| 129 |
-
await expect(page.locator('text=System Prompt')).toBeVisible();
|
| 130 |
-
}
|
| 131 |
-
});
|
| 132 |
-
|
| 133 |
-
test('should not render unexpected characters (like "0") in message bubbles', async ({ page }) => {
|
| 134 |
-
// Wait for page to load
|
| 135 |
-
await page.waitForLoadState('networkidle');
|
| 136 |
-
|
| 137 |
-
// Check if Messages section exists
|
| 138 |
-
const messagesHeading = page.locator('h2:has-text("Messages")');
|
| 139 |
-
await expect(messagesHeading).toBeVisible({ timeout: 10000 });
|
| 140 |
-
|
| 141 |
-
// Get all message bubble containers
|
| 142 |
-
const messageBubbles = page.locator('.rounded-lg.p-4').filter({
|
| 143 |
-
has: page.locator('.text-sm.whitespace-pre-wrap')
|
| 144 |
-
});
|
| 145 |
-
|
| 146 |
-
const messageCount = await messageBubbles.count();
|
| 147 |
-
|
| 148 |
-
// If there are messages, verify none have unexpected "0" characters
|
| 149 |
-
if (messageCount > 0) {
|
| 150 |
-
for (let i = 0; i < messageCount; i++) {
|
| 151 |
-
const bubble = messageBubbles.nth(i);
|
| 152 |
-
const bubbleText = await bubble.textContent();
|
| 153 |
-
|
| 154 |
-
// Message bubbles should not end with a standalone "0"
|
| 155 |
-
// This would indicate the isHistorical field (0) is being rendered
|
| 156 |
-
expect(bubbleText?.trim().endsWith('\n0')).toBe(false);
|
| 157 |
-
expect(bubbleText?.trim().endsWith(' 0')).toBe(false);
|
| 158 |
-
|
| 159 |
-
// Also check that the bubble doesn't contain "0" as the last line
|
| 160 |
-
const lines = bubbleText?.split('\n').map(l => l.trim()).filter(l => l.length > 0) || [];
|
| 161 |
-
if (lines.length > 0) {
|
| 162 |
-
const lastLine = lines[lines.length - 1];
|
| 163 |
-
// Last line should not be a standalone "0" (unless it's actually part of the message content)
|
| 164 |
-
if (lastLine === '0') {
|
| 165 |
-
// This is the bug - isHistorical (0) is being rendered
|
| 166 |
-
throw new Error(`Found unexpected "0" character in message bubble ${i}. This likely means isHistorical field is being rendered.`);
|
| 167 |
-
}
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
console.log(`✅ Verified ${messageCount} message bubbles have no unexpected "0" characters`);
|
| 172 |
-
} else {
|
| 173 |
-
// No messages is acceptable - the test conversation might not have messages yet
|
| 174 |
-
console.log('ℹ️ No messages found - test passes (conversation has no messages)');
|
| 175 |
-
}
|
| 176 |
-
});
|
| 177 |
-
|
| 178 |
-
test('should handle non-existent conversation', async ({ page }) => {
|
| 179 |
-
await page.goto('/admin/conversations/00000000-0000-0000-0000-000000000000');
|
| 180 |
-
|
| 181 |
-
// Should show error
|
| 182 |
-
await expect(page.locator('text=Error')).toBeVisible();
|
| 183 |
-
await expect(page.locator('text=Conversation not found')).toBeVisible();
|
| 184 |
-
});
|
| 185 |
-
});
|
| 186 |
-
|
| 187 |
-
test.describe('Prompts Pages', () => {
|
| 188 |
-
test.describe('Prompts List', () => {
|
| 189 |
-
test.beforeEach(async ({ page }) => {
|
| 190 |
-
await page.goto('/admin/prompts');
|
| 191 |
-
});
|
| 192 |
-
|
| 193 |
-
test('should load prompts list page', async ({ page }) => {
|
| 194 |
-
await expect(page.getByRole('heading', { name: '提示詞管理' })).toBeVisible();
|
| 195 |
-
});
|
| 196 |
-
|
| 197 |
-
test('should display prompts table', async ({ page }) => {
|
| 198 |
-
await page.waitForSelector('table', { timeout: 10000 });
|
| 199 |
-
|
| 200 |
-
// Table headers are in Chinese
|
| 201 |
-
await expect(page.locator('th:has-text("名稱")')).toBeVisible(); // Name
|
| 202 |
-
await expect(page.locator('th:has-text("類型")')).toBeVisible(); // Type
|
| 203 |
-
await expect(page.locator('th:has-text("狀態")')).toBeVisible(); // Status
|
| 204 |
-
});
|
| 205 |
-
|
| 206 |
-
test('should show student prompts', async ({ page }) => {
|
| 207 |
-
// Wait for table to load, then check for student prompt IDs
|
| 208 |
-
await page.waitForSelector('table', { timeout: 10000 });
|
| 209 |
-
await expect(page.locator('td:has-text("ruirui")').first()).toBeVisible();
|
| 210 |
-
});
|
| 211 |
-
|
| 212 |
-
test('should show coach prompts', async ({ page }) => {
|
| 213 |
-
// Wait for table to load, then check for coach prompt IDs
|
| 214 |
-
await page.waitForSelector('table', { timeout: 10000 });
|
| 215 |
-
await expect(page.locator('td:has-text("satir")').first()).toBeVisible();
|
| 216 |
-
});
|
| 217 |
-
|
| 218 |
-
test('should have filter dropdowns', async ({ page }) => {
|
| 219 |
-
// Check that filter select elements exist
|
| 220 |
-
const selects = page.locator('select');
|
| 221 |
-
await expect(selects.first()).toBeVisible();
|
| 222 |
-
// Verify the dropdowns have the expected options by checking select count
|
| 223 |
-
const selectCount = await selects.count();
|
| 224 |
-
expect(selectCount).toBeGreaterThanOrEqual(2); // Type and Status filters
|
| 225 |
-
});
|
| 226 |
-
|
| 227 |
-
test('should filter by student prompts', async ({ page }) => {
|
| 228 |
-
// Use first select (type filter) to filter to student prompts
|
| 229 |
-
await page.selectOption('select >> nth=0', 'student');
|
| 230 |
-
|
| 231 |
-
// Should show student prompts
|
| 232 |
-
await expect(page.locator('td:has-text("ruirui")').first()).toBeVisible();
|
| 233 |
-
});
|
| 234 |
-
|
| 235 |
-
test('should filter by coach prompts', async ({ page }) => {
|
| 236 |
-
// Use first select (type filter) to filter to coach prompts
|
| 237 |
-
await page.selectOption('select >> nth=0', 'coach');
|
| 238 |
-
|
| 239 |
-
// Should show coach prompts
|
| 240 |
-
await expect(page.locator('td:has-text("satir")').first()).toBeVisible();
|
| 241 |
-
});
|
| 242 |
-
|
| 243 |
-
test('should have create new prompt button', async ({ page }) => {
|
| 244 |
-
// Button text is "新增提示詞" (Chinese for "Add Prompt")
|
| 245 |
-
await expect(page.locator('a[href="/admin/prompts/new"]')).toBeVisible();
|
| 246 |
-
await expect(page.locator('text=新增提示詞')).toBeVisible();
|
| 247 |
-
});
|
| 248 |
-
|
| 249 |
-
test('should navigate to prompt detail', async ({ page }) => {
|
| 250 |
-
await page.waitForSelector('a[href*="/admin/prompts/ruirui"]', { timeout: 10000 });
|
| 251 |
-
|
| 252 |
-
await page.click('a[href*="/admin/prompts/ruirui"]');
|
| 253 |
-
await page.waitForURL('**/admin/prompts/ruirui');
|
| 254 |
-
|
| 255 |
-
// Heading is in Chinese: "編輯提示詞"
|
| 256 |
-
await expect(page.getByRole('heading', { name: '編輯提示詞' })).toBeVisible();
|
| 257 |
-
});
|
| 258 |
-
});
|
| 259 |
-
|
| 260 |
-
test.describe('Prompt Detail', () => {
|
| 261 |
-
test.beforeEach(async ({ page }) => {
|
| 262 |
-
await page.goto('/admin/prompts/ruirui');
|
| 263 |
-
});
|
| 264 |
-
|
| 265 |
-
test('should load prompt edit page', async ({ page }) => {
|
| 266 |
-
await expect(page.getByRole('heading', { name: '編輯提示詞' })).toBeVisible();
|
| 267 |
-
});
|
| 268 |
-
|
| 269 |
-
test('should display prompt form fields', async ({ page }) => {
|
| 270 |
-
await page.waitForSelector('input[name="name"], #name', { timeout: 10000 });
|
| 271 |
-
|
| 272 |
-
// Should have name field
|
| 273 |
-
await expect(page.locator('input[name="name"], #name')).toBeVisible();
|
| 274 |
-
|
| 275 |
-
// Should have description field
|
| 276 |
-
await expect(page.locator('textarea[name="description"], #description')).toBeVisible();
|
| 277 |
-
|
| 278 |
-
// Should have system prompt field
|
| 279 |
-
await expect(page.locator('textarea[name="systemPrompt"], #systemPrompt')).toBeVisible();
|
| 280 |
-
});
|
| 281 |
-
|
| 282 |
-
test('should show prompt type info', async ({ page }) => {
|
| 283 |
-
// Type info is displayed but label text is in Chinese, just verify page loaded
|
| 284 |
-
await expect(page.getByRole('heading')).toBeVisible();
|
| 285 |
-
});
|
| 286 |
-
|
| 287 |
-
test('should have save button', async ({ page }) => {
|
| 288 |
-
// Button text is "儲存變更" (Save Changes)
|
| 289 |
-
await expect(page.locator('button:has-text("儲存變更")')).toBeVisible();
|
| 290 |
-
});
|
| 291 |
-
|
| 292 |
-
test('should have back button', async ({ page }) => {
|
| 293 |
-
// Cancel button text is "取消" (Cancel)
|
| 294 |
-
const backLink = page.locator('a[href="/admin/prompts"]:has-text("取消")');
|
| 295 |
-
await expect(backLink).toBeVisible();
|
| 296 |
-
|
| 297 |
-
await backLink.click();
|
| 298 |
-
await page.waitForURL('**/admin/prompts');
|
| 299 |
-
});
|
| 300 |
-
});
|
| 301 |
-
|
| 302 |
-
test.describe('Create Prompt Page', () => {
|
| 303 |
-
test.beforeEach(async ({ page }) => {
|
| 304 |
-
await page.goto('/admin/prompts/new');
|
| 305 |
-
});
|
| 306 |
-
|
| 307 |
-
test('should load create prompt page', async ({ page }) => {
|
| 308 |
-
// Heading is "新增提示詞" (Create New Prompt)
|
| 309 |
-
await expect(page.getByRole('heading', { name: '新增提示詞' })).toBeVisible();
|
| 310 |
-
});
|
| 311 |
-
|
| 312 |
-
test('should have form fields', async ({ page }) => {
|
| 313 |
-
// Wait for form to load
|
| 314 |
-
await page.waitForSelector('form', { timeout: 10000 });
|
| 315 |
-
|
| 316 |
-
// Should have text input fields (ID, name, etc.)
|
| 317 |
-
const inputs = page.locator('input[type="text"]');
|
| 318 |
-
await expect(inputs.first()).toBeVisible();
|
| 319 |
-
|
| 320 |
-
// Should have radio buttons for type selection (student/coach)
|
| 321 |
-
const radios = page.locator('input[type="radio"]');
|
| 322 |
-
await expect(radios.first()).toBeVisible();
|
| 323 |
-
});
|
| 324 |
-
|
| 325 |
-
test('should have create button', async ({ page }) => {
|
| 326 |
-
// Button text is "建立提示詞" (Create Prompt)
|
| 327 |
-
await expect(page.locator('button:has-text("建立提示詞")')).toBeVisible();
|
| 328 |
-
});
|
| 329 |
-
|
| 330 |
-
test('should have cancel/back button', async ({ page }) => {
|
| 331 |
-
// Cancel button text is "取消"
|
| 332 |
-
await expect(page.locator('a[href="/admin/prompts"]:has-text("取消")')).toBeVisible();
|
| 333 |
-
});
|
| 334 |
-
});
|
| 335 |
-
});
|
| 336 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/admin-create-student-prompt.spec.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
/**
|
| 4 |
-
* E2E test: Admin creates student prompt → Student appears in new conversation modal
|
| 5 |
-
*
|
| 6 |
-
* This test verifies the complete user flow:
|
| 7 |
-
* 1. Admin creates a new student prompt via admin UI
|
| 8 |
-
* 2. Navigate to dashboard
|
| 9 |
-
* 3. Open "new conversation" modal
|
| 10 |
-
* 4. Verify the newly created student appears in the list
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 14 |
-
|
| 15 |
-
// Helper function to register and login
|
| 16 |
-
async function registerAndLogin(page: any, username: string) {
|
| 17 |
-
await page.goto(`${baseURL}/login`);
|
| 18 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 19 |
-
await page.fill('input[name="username"]', username);
|
| 20 |
-
await page.click('button:has-text("建立帳號")');
|
| 21 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
test.describe('Admin Create Student Prompt UI Flow', () => {
|
| 25 |
-
test('should create student prompt and see it in new conversation modal', async ({ page }) => {
|
| 26 |
-
// Step 0: Register and login
|
| 27 |
-
const testUser = `admin_prompt_test_${Date.now()}`;
|
| 28 |
-
await registerAndLogin(page, testUser);
|
| 29 |
-
|
| 30 |
-
// Step 1: Navigate to admin new prompt page
|
| 31 |
-
await page.goto(`${baseURL}/admin/prompts/new`);
|
| 32 |
-
await page.waitForLoadState('networkidle');
|
| 33 |
-
|
| 34 |
-
// Step 2: Fill in the form for a new student prompt
|
| 35 |
-
const uniqueId = `e2e_test_student_${Date.now()}`;
|
| 36 |
-
const studentName = `E2E Test Student`;
|
| 37 |
-
|
| 38 |
-
await page.fill('#id', uniqueId);
|
| 39 |
-
await page.fill('#name', studentName);
|
| 40 |
-
await page.fill('#description', 'E2E test student description');
|
| 41 |
-
|
| 42 |
-
// Select student type (radio buttons, not select)
|
| 43 |
-
await page.click('input[type="radio"][value="student"]');
|
| 44 |
-
|
| 45 |
-
// Fill system prompt (must be at least 50 characters)
|
| 46 |
-
await page.fill(
|
| 47 |
-
'#systemPrompt',
|
| 48 |
-
'This is an E2E test student prompt with more than 50 characters for testing purposes.'
|
| 49 |
-
);
|
| 50 |
-
|
| 51 |
-
// Step 3: Submit the form
|
| 52 |
-
await page.click('button[type="submit"]');
|
| 53 |
-
|
| 54 |
-
// Wait for redirect or success
|
| 55 |
-
await page.waitForURL(/\/admin\/prompts/, { timeout: 5000 });
|
| 56 |
-
|
| 57 |
-
// Step 4: Navigate to dashboard
|
| 58 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 59 |
-
await page.waitForLoadState('networkidle');
|
| 60 |
-
|
| 61 |
-
// Wait for dashboard to load (wait for "指定對話演練" card which always exists)
|
| 62 |
-
await expect(page.locator('h3:has-text("指定對話演練")')).toBeVisible({ timeout: 10000 });
|
| 63 |
-
|
| 64 |
-
// Step 5: Click "指定對話演練" card to open modal
|
| 65 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 66 |
-
|
| 67 |
-
// Wait for student selection modal to appear
|
| 68 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible({ timeout: 5000 });
|
| 69 |
-
|
| 70 |
-
// Wait for student list to load
|
| 71 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible({ timeout: 5000 });
|
| 72 |
-
|
| 73 |
-
// Step 7: Verify the newly created student appears in the list
|
| 74 |
-
const newStudentButton = page.locator(`text=${studentName}`).first();
|
| 75 |
-
await expect(newStudentButton).toBeVisible({ timeout: 5000 });
|
| 76 |
-
|
| 77 |
-
console.log(`✅ Student "${studentName}" successfully appears in new conversation modal`);
|
| 78 |
-
});
|
| 79 |
-
|
| 80 |
-
test('should immediately reflect newly created student without page refresh', async ({ page }) => {
|
| 81 |
-
// Step 0: Register and login
|
| 82 |
-
const testUser = `admin_immediate_test_${Date.now()}`;
|
| 83 |
-
await registerAndLogin(page, testUser);
|
| 84 |
-
|
| 85 |
-
// Navigate to dashboard first
|
| 86 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 87 |
-
await page.waitForLoadState('networkidle');
|
| 88 |
-
|
| 89 |
-
// Open new conversation modal and check initial student count
|
| 90 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 91 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible({ timeout: 5000 });
|
| 92 |
-
|
| 93 |
-
const initialStudents = await page.locator('[class*="student"]').count();
|
| 94 |
-
|
| 95 |
-
// Close modal by clicking outside (click on backdrop)
|
| 96 |
-
await page.click('.fixed.inset-0');
|
| 97 |
-
await expect(page.locator('h3:has-text("選擇學生")')).not.toBeVisible({ timeout: 5000 });
|
| 98 |
-
|
| 99 |
-
// Create new student via admin page in new tab
|
| 100 |
-
const uniqueId = `e2e_immediate_${Date.now()}`;
|
| 101 |
-
const studentName = `Immediate Test Student`;
|
| 102 |
-
|
| 103 |
-
await page.goto(`${baseURL}/admin/prompts/new`);
|
| 104 |
-
await page.fill('#id', uniqueId);
|
| 105 |
-
await page.fill('#name', studentName);
|
| 106 |
-
await page.fill('#description', 'Immediate test');
|
| 107 |
-
await page.click('input[type="radio"][value="student"]');
|
| 108 |
-
await page.fill(
|
| 109 |
-
'#systemPrompt',
|
| 110 |
-
'Immediate test student prompt with at least 50 characters for validation.'
|
| 111 |
-
);
|
| 112 |
-
await page.click('button[type="submit"]');
|
| 113 |
-
await page.waitForURL(/\/admin\/prompts/, { timeout: 5000 });
|
| 114 |
-
|
| 115 |
-
// Go back to dashboard
|
| 116 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 117 |
-
await page.waitForLoadState('networkidle');
|
| 118 |
-
|
| 119 |
-
// Re-open modal - should now show the new student
|
| 120 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 121 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible({ timeout: 5000 });
|
| 122 |
-
|
| 123 |
-
// Verify new student appears
|
| 124 |
-
await expect(page.locator(`text=${studentName}`).first()).toBeVisible({ timeout: 5000 });
|
| 125 |
-
|
| 126 |
-
console.log(`✅ Newly created student appears immediately after creation`);
|
| 127 |
-
});
|
| 128 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/admin-dashboard.ui.spec.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
test.describe('Admin Dashboard UI', () => {
|
| 4 |
-
test.beforeEach(async ({ page }) => {
|
| 5 |
-
await page.goto('/admin');
|
| 6 |
-
});
|
| 7 |
-
|
| 8 |
-
test('should load dashboard page successfully', async ({ page }) => {
|
| 9 |
-
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
| 10 |
-
});
|
| 11 |
-
|
| 12 |
-
test('should display statistics cards', async ({ page }) => {
|
| 13 |
-
// Wait for stats to load
|
| 14 |
-
await page.waitForSelector('text=Total Users', { timeout: 10000 });
|
| 15 |
-
|
| 16 |
-
// Check for all stat categories (look for h3 headings in the cards)
|
| 17 |
-
await expect(page.locator('h3.text-sm:has-text("Total Users")')).toBeVisible();
|
| 18 |
-
await expect(page.locator('h3.text-sm:has-text("Conversations")')).toBeVisible();
|
| 19 |
-
await expect(page.locator('h3.text-sm:has-text("Total Messages")')).toBeVisible();
|
| 20 |
-
await expect(page.locator('h3.text-sm:has-text("Prompts")')).toBeVisible();
|
| 21 |
-
});
|
| 22 |
-
|
| 23 |
-
test('should display health metrics table', async ({ page }) => {
|
| 24 |
-
// Wait for health data to load
|
| 25 |
-
await page.waitForSelector('text=Database Health', { timeout: 10000 });
|
| 26 |
-
|
| 27 |
-
await expect(page.locator('text=Database Health')).toBeVisible();
|
| 28 |
-
|
| 29 |
-
// Check for table headers
|
| 30 |
-
await expect(page.locator('text=Table')).toBeVisible();
|
| 31 |
-
await expect(page.locator('text=Row Count')).toBeVisible();
|
| 32 |
-
await expect(page.locator('text=Last Modified')).toBeVisible();
|
| 33 |
-
});
|
| 34 |
-
|
| 35 |
-
test('should show expected database tables', async ({ page }) => {
|
| 36 |
-
await page.waitForSelector('text=Database Health', { timeout: 10000 });
|
| 37 |
-
|
| 38 |
-
// Verify all expected tables are listed - look for td with exact table names
|
| 39 |
-
await expect(page.locator('td.px-6.py-4:has-text("users")').first()).toBeVisible();
|
| 40 |
-
await expect(page.locator('td.px-6.py-4:has-text("conversations")').first()).toBeVisible();
|
| 41 |
-
await expect(page.locator('td.px-6.py-4:has-text("messages")').first()).toBeVisible();
|
| 42 |
-
await expect(page.locator('td.px-6.py-4:has-text("sessions")').first()).toBeVisible();
|
| 43 |
-
await expect(page.locator('td.px-6.py-4:has-text("prompt_templates")').first()).toBeVisible();
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
test('should have navigation links', async ({ page }) => {
|
| 47 |
-
// Check main navigation links exist (using .first() since there are sidebar + quick links)
|
| 48 |
-
await expect(page.locator('a[href="/admin"]').first()).toBeVisible();
|
| 49 |
-
await expect(page.locator('a[href="/admin/users"]').first()).toBeVisible();
|
| 50 |
-
await expect(page.locator('a[href="/admin/conversations"]').first()).toBeVisible();
|
| 51 |
-
await expect(page.locator('a[href="/admin/prompts"]').first()).toBeVisible();
|
| 52 |
-
});
|
| 53 |
-
|
| 54 |
-
test('should navigate to users page', async ({ page }) => {
|
| 55 |
-
await page.click('a[href="/admin/users"]');
|
| 56 |
-
await page.waitForURL('**/admin/users');
|
| 57 |
-
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
|
| 58 |
-
});
|
| 59 |
-
|
| 60 |
-
test('should navigate to conversations page', async ({ page }) => {
|
| 61 |
-
await page.click('a[href="/admin/conversations"]');
|
| 62 |
-
await page.waitForURL('**/admin/conversations');
|
| 63 |
-
await expect(page.getByRole('heading', { name: 'Conversations' })).toBeVisible();
|
| 64 |
-
});
|
| 65 |
-
|
| 66 |
-
test('should navigate to prompts page', async ({ page }) => {
|
| 67 |
-
await page.click('a[href="/admin/prompts"]');
|
| 68 |
-
await page.waitForURL('**/admin/prompts');
|
| 69 |
-
await expect(page.getByRole('heading', { name: '提示詞管理' })).toBeVisible();
|
| 70 |
-
});
|
| 71 |
-
|
| 72 |
-
test('should display reasonable stat values', async ({ page }) => {
|
| 73 |
-
await page.waitForSelector('text=Total Users', { timeout: 10000 });
|
| 74 |
-
|
| 75 |
-
// Get stat values from the cards (numbers are in p tags inside stat cards)
|
| 76 |
-
const statCards = page.locator('.bg-white.rounded-lg.shadow.p-6');
|
| 77 |
-
const firstCardNumber = statCards.first().locator('p.text-3xl');
|
| 78 |
-
const value = await firstCardNumber.textContent();
|
| 79 |
-
|
| 80 |
-
// Should be a number
|
| 81 |
-
expect(value?.trim()).toMatch(/^\d+$/);
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
test('should handle loading state gracefully', async ({ page }) => {
|
| 85 |
-
// Reload to see loading state
|
| 86 |
-
const responsePromise = page.waitForResponse(response =>
|
| 87 |
-
response.url().includes('/api/admin/stats') && response.status() === 200
|
| 88 |
-
);
|
| 89 |
-
|
| 90 |
-
await page.reload();
|
| 91 |
-
|
| 92 |
-
// Should show some content while loading (skeleton or spinner)
|
| 93 |
-
await expect(page.locator('body')).toBeVisible();
|
| 94 |
-
|
| 95 |
-
await responsePromise;
|
| 96 |
-
|
| 97 |
-
// Stats should appear after loading
|
| 98 |
-
await expect(page.locator('text=Total Users')).toBeVisible();
|
| 99 |
-
});
|
| 100 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/admin-users.ui.spec.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const BASE_URL = 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('Admin Users UI', () => {
|
| 6 |
-
let testUserId: string;
|
| 7 |
-
let testUsername: string;
|
| 8 |
-
|
| 9 |
-
test.beforeAll(async ({ request }) => {
|
| 10 |
-
// Create a test user for UI tests
|
| 11 |
-
testUsername = `uitest_${Date.now()}`;
|
| 12 |
-
const response = await request.post(`${BASE_URL}/api/auth/register`, {
|
| 13 |
-
data: { username: testUsername },
|
| 14 |
-
});
|
| 15 |
-
|
| 16 |
-
const data = await response.json();
|
| 17 |
-
testUserId = data.user.id;
|
| 18 |
-
});
|
| 19 |
-
|
| 20 |
-
test.describe('Users List Page', () => {
|
| 21 |
-
test.beforeEach(async ({ page }) => {
|
| 22 |
-
await page.goto('/admin/users');
|
| 23 |
-
});
|
| 24 |
-
|
| 25 |
-
test('should load users list page', async ({ page }) => {
|
| 26 |
-
await expect(page.getByRole('heading', { name: 'Users Management' })).toBeVisible();
|
| 27 |
-
});
|
| 28 |
-
|
| 29 |
-
test('should display users table on desktop', async ({ page }) => {
|
| 30 |
-
// Wait for data to load
|
| 31 |
-
await page.waitForSelector('table, .space-y-4', { timeout: 10000 });
|
| 32 |
-
|
| 33 |
-
// On desktop, should show table
|
| 34 |
-
await page.setViewportSize({ width: 1280, height: 720 });
|
| 35 |
-
await page.waitForSelector('table', { timeout: 5000 });
|
| 36 |
-
|
| 37 |
-
// Verify table headers
|
| 38 |
-
await expect(page.locator('th:has-text("Username")')).toBeVisible();
|
| 39 |
-
await expect(page.locator('th:has-text("Conversations")')).toBeVisible();
|
| 40 |
-
await expect(page.locator('th:has-text("Messages")')).toBeVisible();
|
| 41 |
-
});
|
| 42 |
-
|
| 43 |
-
test('should display users cards on mobile', async ({ page }) => {
|
| 44 |
-
// Set mobile viewport
|
| 45 |
-
await page.setViewportSize({ width: 375, height: 667 });
|
| 46 |
-
|
| 47 |
-
// Wait for cards to appear
|
| 48 |
-
await page.waitForSelector('.bg-white.rounded-lg.shadow', { timeout: 10000 });
|
| 49 |
-
|
| 50 |
-
// Should show cards instead of table
|
| 51 |
-
const cards = page.locator('.bg-white.rounded-lg.shadow');
|
| 52 |
-
await expect(cards.first()).toBeVisible();
|
| 53 |
-
});
|
| 54 |
-
|
| 55 |
-
test('should show search input', async ({ page }) => {
|
| 56 |
-
await expect(page.locator('input[placeholder*="Search"]')).toBeVisible();
|
| 57 |
-
});
|
| 58 |
-
|
| 59 |
-
test('should search for users', async ({ page }) => {
|
| 60 |
-
await page.waitForSelector('input[placeholder*="Search"]', { timeout: 10000 });
|
| 61 |
-
|
| 62 |
-
const searchInput = page.locator('input[placeholder*="Search"]');
|
| 63 |
-
await searchInput.fill(testUsername);
|
| 64 |
-
|
| 65 |
-
// Wait for search to complete (expect automatically waits for element)
|
| 66 |
-
await expect(page.locator(`td:has-text("${testUsername}"), h3:has-text("${testUsername}")`).first()).toBeVisible({ timeout: 1000 });
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
test('should show results count', async ({ page }) => {
|
| 70 |
-
await page.waitForSelector('text=Showing', { timeout: 10000 });
|
| 71 |
-
|
| 72 |
-
// Should display results count "Showing X of Y users"
|
| 73 |
-
await expect(page.locator('div.mt-4.text-sm.text-gray-600')).toContainText('Showing');
|
| 74 |
-
await expect(page.locator('div.mt-4.text-sm.text-gray-600')).toContainText('users');
|
| 75 |
-
});
|
| 76 |
-
|
| 77 |
-
test('should have sort controls', async ({ page }) => {
|
| 78 |
-
// Should have sort by dropdown
|
| 79 |
-
await expect(page.locator('select, button:has-text("Sort")')).toBeVisible();
|
| 80 |
-
});
|
| 81 |
-
|
| 82 |
-
test('should navigate to user detail when clicking view details', async ({ page }) => {
|
| 83 |
-
// Wait for the user's row to appear
|
| 84 |
-
await page.waitForSelector(`text=${testUsername}`, { timeout: 10000 });
|
| 85 |
-
|
| 86 |
-
// Click on "View Details" link
|
| 87 |
-
const viewDetailsLink = page.locator(`a[href="/admin/users/${testUserId}"]`).first();
|
| 88 |
-
await viewDetailsLink.click();
|
| 89 |
-
|
| 90 |
-
// Should navigate to user detail page
|
| 91 |
-
await page.waitForURL(`**/admin/users/${testUserId}`);
|
| 92 |
-
await expect(page.getByRole('heading', { name: 'User Details' })).toBeVisible();
|
| 93 |
-
});
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
test.describe('User Detail Page', () => {
|
| 97 |
-
test.beforeEach(async ({ page }) => {
|
| 98 |
-
await page.goto(`/admin/users/${testUserId}`);
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
test('should load user detail page', async ({ page }) => {
|
| 102 |
-
await expect(page.getByRole('heading', { name: 'User Details' })).toBeVisible();
|
| 103 |
-
});
|
| 104 |
-
|
| 105 |
-
test('should display user information', async ({ page }) => {
|
| 106 |
-
await page.waitForSelector(`text=${testUsername}`, { timeout: 10000 });
|
| 107 |
-
|
| 108 |
-
// Should show username
|
| 109 |
-
await expect(page.locator(`h2:has-text("${testUsername}")`)).toBeVisible();
|
| 110 |
-
|
| 111 |
-
// Should show user ID
|
| 112 |
-
await expect(page.locator(`text=User ID:`)).toBeVisible();
|
| 113 |
-
});
|
| 114 |
-
|
| 115 |
-
test('should show registration date', async ({ page }) => {
|
| 116 |
-
await expect(page.locator('text=Registered')).toBeVisible();
|
| 117 |
-
});
|
| 118 |
-
|
| 119 |
-
test('should show last active timestamp', async ({ page }) => {
|
| 120 |
-
await expect(page.locator('text=Last Active')).toBeVisible();
|
| 121 |
-
});
|
| 122 |
-
|
| 123 |
-
test('should display activity stats', async ({ page }) => {
|
| 124 |
-
// In the detail page, stats are shown in the info card
|
| 125 |
-
await expect(page.locator('text=Activity')).toBeVisible();
|
| 126 |
-
await expect(page.locator('p.text-lg.font-semibold.text-gray-900').filter({ hasText: 'conversations' })).toBeVisible();
|
| 127 |
-
await expect(page.locator('p.text-xs.text-gray-500').filter({ hasText: 'messages sent' })).toBeVisible();
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
-
test('should show recent conversations section', async ({ page }) => {
|
| 131 |
-
await expect(page.locator('text=Recent Conversations')).toBeVisible();
|
| 132 |
-
});
|
| 133 |
-
|
| 134 |
-
test('should have back to users button', async ({ page }) => {
|
| 135 |
-
const backLink = page.locator('a[href="/admin/users"]:has-text("Back")');
|
| 136 |
-
await expect(backLink).toBeVisible();
|
| 137 |
-
|
| 138 |
-
await backLink.click();
|
| 139 |
-
await page.waitForURL('**/admin/users');
|
| 140 |
-
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
|
| 141 |
-
});
|
| 142 |
-
|
| 143 |
-
test('should show no conversations message for new user', async ({ page }) => {
|
| 144 |
-
// New user should have no conversations
|
| 145 |
-
await page.waitForSelector('text=Recent Conversations', { timeout: 10000 });
|
| 146 |
-
await expect(page.locator('text=No conversations yet')).toBeVisible();
|
| 147 |
-
});
|
| 148 |
-
|
| 149 |
-
test('should handle error state gracefully', async ({ page }) => {
|
| 150 |
-
// Navigate to non-existent user
|
| 151 |
-
await page.goto('/admin/users/00000000-0000-0000-0000-000000000000');
|
| 152 |
-
|
| 153 |
-
// Should show error message
|
| 154 |
-
await expect(page.locator('text=Error')).toBeVisible();
|
| 155 |
-
await expect(page.locator('text=User not found')).toBeVisible();
|
| 156 |
-
|
| 157 |
-
// Should have retry button
|
| 158 |
-
await expect(page.locator('button:has-text("Retry")')).toBeVisible();
|
| 159 |
-
|
| 160 |
-
// Should have back button
|
| 161 |
-
await expect(page.locator('text=Back to Users')).toBeVisible();
|
| 162 |
-
});
|
| 163 |
-
|
| 164 |
-
test('should show error state with retry functionality', async ({ page }) => {
|
| 165 |
-
await page.goto('/admin/users/invalid-id');
|
| 166 |
-
|
| 167 |
-
// Should show error
|
| 168 |
-
await expect(page.locator('text=Error')).toBeVisible();
|
| 169 |
-
|
| 170 |
-
// Back button should work
|
| 171 |
-
await page.click('text=Back to Users');
|
| 172 |
-
await page.waitForURL('**/admin/users');
|
| 173 |
-
});
|
| 174 |
-
});
|
| 175 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-ad-prompt.spec.ts
DELETED
|
@@ -1,315 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
import { execSync } from 'child_process';
|
| 3 |
-
|
| 4 |
-
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
| 5 |
-
const PASSWORD = process.env.BASIC_AUTH_PASSWORD || 'cz-2025';
|
| 6 |
-
|
| 7 |
-
// Helper function to register and login via UI
|
| 8 |
-
async function registerAndLogin(page: any, username: string) {
|
| 9 |
-
await page.goto(`${API_URL}/login`);
|
| 10 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 11 |
-
await page.fill('input[name="username"]', username);
|
| 12 |
-
await page.click('button:has-text("建立帳號")');
|
| 13 |
-
await expect(page).toHaveURL(`${API_URL}/dashboard`, { timeout: 10_000 });
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
test.describe('Coach Ad Prompt Feature', () => {
|
| 17 |
-
let authHeader: string;
|
| 18 |
-
let userId: string;
|
| 19 |
-
let username: string;
|
| 20 |
-
let conversationId: string;
|
| 21 |
-
|
| 22 |
-
test.beforeAll(async ({ request }) => {
|
| 23 |
-
// Register a unique user for this test suite
|
| 24 |
-
const timestamp = Date.now();
|
| 25 |
-
username = `coach-ad-test-${timestamp}`;
|
| 26 |
-
authHeader = `Basic ${Buffer.from(`${username}:${PASSWORD}`).toString('base64')}`;
|
| 27 |
-
|
| 28 |
-
const registerResponse = await request.post(`${API_URL}/api/auth/register`, {
|
| 29 |
-
data: {
|
| 30 |
-
username,
|
| 31 |
-
password: PASSWORD,
|
| 32 |
-
},
|
| 33 |
-
});
|
| 34 |
-
|
| 35 |
-
expect(registerResponse.ok()).toBeTruthy();
|
| 36 |
-
const registerData = await registerResponse.json();
|
| 37 |
-
userId = registerData.user.id;
|
| 38 |
-
|
| 39 |
-
// Create a conversation with a student (not coach_direct)
|
| 40 |
-
const createConvResponse = await request.post(`${API_URL}/api/conversations/create`, {
|
| 41 |
-
headers: { Authorization: authHeader },
|
| 42 |
-
data: {
|
| 43 |
-
studentPromptId: 'ruirui',
|
| 44 |
-
coachPromptId: 'satir',
|
| 45 |
-
include3ConversationSummary: false,
|
| 46 |
-
},
|
| 47 |
-
});
|
| 48 |
-
|
| 49 |
-
expect(createConvResponse.ok()).toBeTruthy();
|
| 50 |
-
const convData = await createConvResponse.json();
|
| 51 |
-
conversationId = convData.conversation.id;
|
| 52 |
-
});
|
| 53 |
-
|
| 54 |
-
test('should show coach ad prompt after 6 user-to-student messages', async ({ page, request }) => {
|
| 55 |
-
test.setTimeout(90_000);
|
| 56 |
-
const testUser = `coach-ad-ui-${Date.now()}`;
|
| 57 |
-
|
| 58 |
-
// Register and login via UI
|
| 59 |
-
await registerAndLogin(page, testUser);
|
| 60 |
-
|
| 61 |
-
// Create conversation via UI
|
| 62 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 63 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 64 |
-
await page.click('text=睿睿');
|
| 65 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 66 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 67 |
-
|
| 68 |
-
// Get conversation ID from URL
|
| 69 |
-
const convId = page.url().match(/\/conversation\/([^/?]+)/)?.[1];
|
| 70 |
-
expect(convId).toBeTruthy();
|
| 71 |
-
|
| 72 |
-
// Set message count to 5 via SQLite (so next message will be #6)
|
| 73 |
-
execSync(
|
| 74 |
-
`sqlite3 ./data/app.db "UPDATE conversations SET user_to_student_message_count = 5 WHERE id = '${convId}';"`,
|
| 75 |
-
{ encoding: 'utf-8' }
|
| 76 |
-
);
|
| 77 |
-
|
| 78 |
-
// Send 6th message (triggers coach ad prompt)
|
| 79 |
-
await page.fill('input[type="text"]', 'Help me with homework');
|
| 80 |
-
await page.click('button[type="submit"]');
|
| 81 |
-
|
| 82 |
-
// Wait for student response to complete (indicates streaming is done)
|
| 83 |
-
const studentResponse = page.locator('.bg-white.border.border-blue-100').first();
|
| 84 |
-
await expect(studentResponse).toBeVisible({ timeout: 60_000 });
|
| 85 |
-
|
| 86 |
-
// Verify coach ad modal appears
|
| 87 |
-
await expect(page.locator('text=諮詢教練建議')).toBeVisible({ timeout: 10_000 });
|
| 88 |
-
await expect(page.locator('text=需要向教練尋求建議嗎')).toBeVisible();
|
| 89 |
-
|
| 90 |
-
console.log('✅ Coach ad prompt appeared after 6 messages');
|
| 91 |
-
});
|
| 92 |
-
|
| 93 |
-
test('should allow dismissing the coach ad prompt', async ({ page, request }) => {
|
| 94 |
-
test.setTimeout(90_000);
|
| 95 |
-
const testUser = `coach-ad-dismiss-${Date.now()}`;
|
| 96 |
-
|
| 97 |
-
// Register and login via UI
|
| 98 |
-
await registerAndLogin(page, testUser);
|
| 99 |
-
|
| 100 |
-
// Create conversation via UI
|
| 101 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 102 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 103 |
-
await page.click('text=睿睿');
|
| 104 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 105 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 106 |
-
|
| 107 |
-
// Get conversation ID from URL
|
| 108 |
-
const convId = page.url().match(/\/conversation\/([^/?]+)/)?.[1];
|
| 109 |
-
expect(convId).toBeTruthy();
|
| 110 |
-
|
| 111 |
-
// Set message count to 5 via SQLite
|
| 112 |
-
execSync(
|
| 113 |
-
`sqlite3 ./data/app.db "UPDATE conversations SET user_to_student_message_count = 5 WHERE id = '${convId}';"`,
|
| 114 |
-
{ encoding: 'utf-8' }
|
| 115 |
-
);
|
| 116 |
-
|
| 117 |
-
// Send 6th message
|
| 118 |
-
await page.fill('input[type="text"]', 'Help me');
|
| 119 |
-
await page.click('button[type="submit"]');
|
| 120 |
-
|
| 121 |
-
// Wait for student response to complete
|
| 122 |
-
const studentResponse = page.locator('.bg-white.border.border-blue-100').first();
|
| 123 |
-
await expect(studentResponse).toBeVisible({ timeout: 60_000 });
|
| 124 |
-
|
| 125 |
-
// Verify modal appears
|
| 126 |
-
await expect(page.locator('text=諮詢教練建議')).toBeVisible({ timeout: 10_000 });
|
| 127 |
-
|
| 128 |
-
// Click dismiss button
|
| 129 |
-
const dismissButton = page.locator('button:has-text("忽略")');
|
| 130 |
-
await expect(dismissButton).toBeVisible();
|
| 131 |
-
await dismissButton.click();
|
| 132 |
-
|
| 133 |
-
// Verify modal is gone
|
| 134 |
-
await expect(page.locator('text=諮詢教練建議')).not.toBeVisible();
|
| 135 |
-
|
| 136 |
-
console.log('✅ Coach ad prompt dismissed successfully');
|
| 137 |
-
});
|
| 138 |
-
|
| 139 |
-
test('should generate and send coach message when clicking OK', async ({ page, request }) => {
|
| 140 |
-
test.setTimeout(90_000);
|
| 141 |
-
const testUser = `coach-ad-accept-${Date.now()}`;
|
| 142 |
-
|
| 143 |
-
// Register and login via UI
|
| 144 |
-
await registerAndLogin(page, testUser);
|
| 145 |
-
|
| 146 |
-
// Create conversation via UI
|
| 147 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 148 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 149 |
-
await page.click('text=睿睿');
|
| 150 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 151 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 152 |
-
|
| 153 |
-
// Get conversation ID from URL
|
| 154 |
-
const convId = page.url().match(/\/conversation\/([^/?]+)/)?.[1];
|
| 155 |
-
expect(convId).toBeTruthy();
|
| 156 |
-
|
| 157 |
-
// Set message count to 5 via SQLite
|
| 158 |
-
execSync(
|
| 159 |
-
`sqlite3 ./data/app.db "UPDATE conversations SET user_to_student_message_count = 5 WHERE id = '${convId}';"`,
|
| 160 |
-
{ encoding: 'utf-8' }
|
| 161 |
-
);
|
| 162 |
-
|
| 163 |
-
// Send 6th message
|
| 164 |
-
await page.fill('input[type="text"]', 'I need help with this');
|
| 165 |
-
await page.click('button[type="submit"]');
|
| 166 |
-
|
| 167 |
-
// Wait for student response to complete
|
| 168 |
-
const studentResponse = page.locator('.bg-white.border.border-blue-100').first();
|
| 169 |
-
await expect(studentResponse).toBeVisible({ timeout: 60_000 });
|
| 170 |
-
|
| 171 |
-
// Verify modal appears
|
| 172 |
-
await expect(page.locator('text=諮詢教練建議')).toBeVisible({ timeout: 10_000 });
|
| 173 |
-
|
| 174 |
-
// Click OK button
|
| 175 |
-
const okButton = page.locator('button:has-text("好的,諮詢教練")');
|
| 176 |
-
await expect(okButton).toBeVisible();
|
| 177 |
-
await okButton.click();
|
| 178 |
-
|
| 179 |
-
// Verify coach message appears (purple gradient background)
|
| 180 |
-
const coachMessage = page.locator('.bg-gradient-to-br.from-purple-50');
|
| 181 |
-
await expect(coachMessage.first()).toBeVisible({ timeout: 60_000 });
|
| 182 |
-
|
| 183 |
-
console.log('✅ Coach message generated after clicking OK');
|
| 184 |
-
});
|
| 185 |
-
|
| 186 |
-
test('should show "Ask Coach" button when message count >= 6', async ({ page, request }) => {
|
| 187 |
-
test.setTimeout(90_000);
|
| 188 |
-
const testUser = `coach-button-${Date.now()}`;
|
| 189 |
-
|
| 190 |
-
// Register and login via UI
|
| 191 |
-
await registerAndLogin(page, testUser);
|
| 192 |
-
|
| 193 |
-
// Create conversation via UI
|
| 194 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 195 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 196 |
-
await page.click('text=睿睿');
|
| 197 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 198 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 199 |
-
|
| 200 |
-
// Get conversation ID from URL
|
| 201 |
-
const convId = page.url().match(/\/conversation\/([^/?]+)/)?.[1];
|
| 202 |
-
expect(convId).toBeTruthy();
|
| 203 |
-
|
| 204 |
-
// Set message count to 6 via SQLite (threshold reached)
|
| 205 |
-
execSync(
|
| 206 |
-
`sqlite3 ./data/app.db "UPDATE conversations SET user_to_student_message_count = 6 WHERE id = '${convId}';"`,
|
| 207 |
-
{ encoding: 'utf-8' }
|
| 208 |
-
);
|
| 209 |
-
|
| 210 |
-
// Reload page to trigger UI update
|
| 211 |
-
await page.reload();
|
| 212 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 213 |
-
|
| 214 |
-
// Verify "Ask Coach" button in header is visible with badge
|
| 215 |
-
const askCoachButton = page.locator('button[title="諮詢教練"]');
|
| 216 |
-
await expect(askCoachButton).toBeVisible();
|
| 217 |
-
|
| 218 |
-
console.log('✅ Ask Coach button visible in header');
|
| 219 |
-
|
| 220 |
-
console.log('✅ Ask Coach button visible when count >= 6');
|
| 221 |
-
});
|
| 222 |
-
|
| 223 |
-
test('should only show "Ask Coach" button when speaker is student (not coach)', async ({ page }) => {
|
| 224 |
-
test.setTimeout(90_000);
|
| 225 |
-
const testUser = `coach-speaker-button-${Date.now()}`;
|
| 226 |
-
|
| 227 |
-
// Register and login via UI
|
| 228 |
-
await registerAndLogin(page, testUser);
|
| 229 |
-
|
| 230 |
-
// Create conversation via UI
|
| 231 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 232 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 233 |
-
await page.click('text=睿睿');
|
| 234 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 235 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 236 |
-
|
| 237 |
-
// Get conversation ID from URL
|
| 238 |
-
const convId = page.url().match(/\/conversation\/([^/?]+)/)?.[1];
|
| 239 |
-
expect(convId).toBeTruthy();
|
| 240 |
-
|
| 241 |
-
// Set message count to 6 to enable the "Ask Coach" button
|
| 242 |
-
execSync(
|
| 243 |
-
`sqlite3 ./data/app.db "UPDATE conversations SET user_to_student_message_count = 6 WHERE id = '${convId}';"`,
|
| 244 |
-
{ encoding: 'utf-8' }
|
| 245 |
-
);
|
| 246 |
-
|
| 247 |
-
// Reload page to update conversation state
|
| 248 |
-
await page.reload();
|
| 249 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 250 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 251 |
-
|
| 252 |
-
// Verify "Ask Coach" button is visible (default speaker is student)
|
| 253 |
-
const askCoachButton = page.locator('button[title="諮詢教練"]');
|
| 254 |
-
await expect(askCoachButton).toBeVisible();
|
| 255 |
-
|
| 256 |
-
console.log('✅ Ask Coach button visible when speaker is student (default)');
|
| 257 |
-
|
| 258 |
-
// NOTE: Testing button visibility when speaker is coach would require:
|
| 259 |
-
// 1. Either manual speaker toggle UI (which is hidden)
|
| 260 |
-
// 2. Or speaker auto-detection from message history (complex to test reliably)
|
| 261 |
-
// The feature itself works correctly - button only shows when speaker === 'student'
|
| 262 |
-
// This is verified by the conditional rendering logic in the component
|
| 263 |
-
|
| 264 |
-
console.log('✅✅✅ Ask Coach button speaker-based visibility test passed!');
|
| 265 |
-
});
|
| 266 |
-
|
| 267 |
-
test('should not show coach ad features for coach_direct conversations', async ({ request }) => {
|
| 268 |
-
// Create a coach_direct conversation
|
| 269 |
-
const createConvResponse = await request.post(`${API_URL}/api/conversations/create`, {
|
| 270 |
-
headers: { Authorization: authHeader },
|
| 271 |
-
data: {
|
| 272 |
-
studentPromptId: 'coach_direct',
|
| 273 |
-
coachPromptId: 'satir',
|
| 274 |
-
include3ConversationSummary: false,
|
| 275 |
-
},
|
| 276 |
-
});
|
| 277 |
-
|
| 278 |
-
expect(createConvResponse.ok()).toBeTruthy();
|
| 279 |
-
const convData = await createConvResponse.json();
|
| 280 |
-
const coachConvId = convData.conversation.id;
|
| 281 |
-
|
| 282 |
-
// Send 20 user messages via API
|
| 283 |
-
for (let i = 1; i <= 20; i++) {
|
| 284 |
-
const messageResponse = await request.post(`${API_URL}/api/conversations/${coachConvId}/message`, {
|
| 285 |
-
headers: {
|
| 286 |
-
'Content-Type': 'application/json',
|
| 287 |
-
Authorization: authHeader,
|
| 288 |
-
},
|
| 289 |
-
data: {
|
| 290 |
-
messages: [
|
| 291 |
-
{
|
| 292 |
-
role: 'user',
|
| 293 |
-
parts: [{ type: 'text', text: `測試訊息 ${i}` }],
|
| 294 |
-
metadata: { speaker: 'coach' }
|
| 295 |
-
}
|
| 296 |
-
]
|
| 297 |
-
}
|
| 298 |
-
});
|
| 299 |
-
|
| 300 |
-
expect(messageResponse.ok()).toBeTruthy();
|
| 301 |
-
}
|
| 302 |
-
|
| 303 |
-
// Fetch the conversation and verify no coachAdFirstShownAt was set
|
| 304 |
-
const convResponse = await request.get(`${API_URL}/api/conversations/${coachConvId}`, {
|
| 305 |
-
headers: { Authorization: authHeader }
|
| 306 |
-
});
|
| 307 |
-
|
| 308 |
-
expect(convResponse.ok()).toBeTruthy();
|
| 309 |
-
const { conversation } = await convResponse.json();
|
| 310 |
-
|
| 311 |
-
// userToStudentMessageCount should be 0 (because speaker is 'coach')
|
| 312 |
-
expect(conversation.userToStudentMessageCount || 0).toBe(0);
|
| 313 |
-
expect(conversation.coachAdFirstShownAt).toBeFalsy(); // Should be null/undefined (not set)
|
| 314 |
-
});
|
| 315 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-chat-persistence.spec.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
test.describe('Coach Chat Persistence', () => {
|
| 15 |
-
test.use({ workers: 1 }); // Run serially to avoid rate limits
|
| 16 |
-
|
| 17 |
-
test('should persist coach chat conversations across page reloads', async ({ page }) => {
|
| 18 |
-
test.setTimeout(60_000);
|
| 19 |
-
const testUser = `coach-test-${Date.now()}`;
|
| 20 |
-
|
| 21 |
-
// Register and login
|
| 22 |
-
await registerAndLogin(page, testUser);
|
| 23 |
-
|
| 24 |
-
// Wait for dashboard
|
| 25 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 26 |
-
|
| 27 |
-
// Click "教練回顧" card to open modal, then click "開始諮詢"
|
| 28 |
-
await page.click('h3:has-text("教練回顧")');
|
| 29 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 30 |
-
await page.click('button:has-text("開始諮詢")');
|
| 31 |
-
|
| 32 |
-
// Wait for conversation page to load (coach conversations now use regular conversation page)
|
| 33 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 34 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 35 |
-
|
| 36 |
-
// Send first message to coach
|
| 37 |
-
const firstMessage = 'Hello coach, I need help with teaching ADHD students.';
|
| 38 |
-
const inputField = page.locator('input[type="text"]');
|
| 39 |
-
await inputField.fill(firstMessage);
|
| 40 |
-
await inputField.press('Enter');
|
| 41 |
-
|
| 42 |
-
// Wait for assistant response to complete (coach messages have purple bg on conversation page)
|
| 43 |
-
const coachMessages = page.locator('.bg-gradient-to-br.from-purple-50 .whitespace-pre-wrap');
|
| 44 |
-
await expect(coachMessages.first()).toBeVisible({ timeout: 60000 });
|
| 45 |
-
const firstResponse = await coachMessages.first().textContent();
|
| 46 |
-
|
| 47 |
-
// Reload the page (message is auto-saved during streaming)
|
| 48 |
-
await page.reload();
|
| 49 |
-
|
| 50 |
-
// Wait for page to reload
|
| 51 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 52 |
-
|
| 53 |
-
// Verify the conversation history is loaded
|
| 54 |
-
await expect(page.getByText(firstMessage)).toBeVisible({ timeout: 10000 });
|
| 55 |
-
await expect(page.locator('.whitespace-pre-wrap').filter({ hasText: firstResponse || '' })).toBeVisible({ timeout: 10000 });
|
| 56 |
-
|
| 57 |
-
// Send another message
|
| 58 |
-
const secondMessage = 'Can you tell me more about that?';
|
| 59 |
-
const inputField2 = page.locator('input[type="text"]');
|
| 60 |
-
await inputField2.fill(secondMessage);
|
| 61 |
-
await inputField2.press('Enter');
|
| 62 |
-
|
| 63 |
-
// Wait for second coach response to appear
|
| 64 |
-
await expect(coachMessages.nth(1)).toBeVisible({ timeout: 60000 });
|
| 65 |
-
|
| 66 |
-
// Verify both messages are still visible
|
| 67 |
-
await expect(page.getByText(firstMessage)).toBeVisible();
|
| 68 |
-
await expect(page.getByText(secondMessage)).toBeVisible();
|
| 69 |
-
|
| 70 |
-
// Navigate away to dashboard
|
| 71 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 72 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 73 |
-
|
| 74 |
-
// Go back to the existing coach conversation from sidebar
|
| 75 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 76 |
-
await expect(page.locator('text=對話記錄')).toBeVisible();
|
| 77 |
-
|
| 78 |
-
// Click on the coach conversation (has "諮詢" in title or coach emoji)
|
| 79 |
-
const coachConv = page.locator('.space-y-1 > div').filter({ hasText: '👨🏫' }).first();
|
| 80 |
-
await expect(coachConv).toBeVisible();
|
| 81 |
-
await coachConv.click();
|
| 82 |
-
|
| 83 |
-
// Wait for conversation to load
|
| 84 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 85 |
-
|
| 86 |
-
// Verify all messages are still there
|
| 87 |
-
await expect(page.getByText(firstMessage)).toBeVisible({ timeout: 10000 });
|
| 88 |
-
await expect(page.getByText(secondMessage)).toBeVisible();
|
| 89 |
-
|
| 90 |
-
console.log('✅ Coach chat persistence test passed!');
|
| 91 |
-
});
|
| 92 |
-
|
| 93 |
-
test('should not show coach-direct conversations in regular conversation list', async ({ page }) => {
|
| 94 |
-
test.setTimeout(60_000);
|
| 95 |
-
const testUser = `sidebar-test-${Date.now()}`;
|
| 96 |
-
|
| 97 |
-
// Register and login
|
| 98 |
-
await registerAndLogin(page, testUser);
|
| 99 |
-
|
| 100 |
-
// Wait for dashboard
|
| 101 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 102 |
-
|
| 103 |
-
// Click "教練回顧" from dashboard to create a coach conversation
|
| 104 |
-
await page.click('h3:has-text("教練回顧")');
|
| 105 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 106 |
-
await page.click('button:has-text("開始諮詢")');
|
| 107 |
-
|
| 108 |
-
// Wait for conversation page to load (coach conversations now use regular conversation page)
|
| 109 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 110 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 111 |
-
|
| 112 |
-
// Send a message
|
| 113 |
-
const testInput = page.locator('input[type="text"]');
|
| 114 |
-
await testInput.fill('Test message');
|
| 115 |
-
await testInput.press('Enter');
|
| 116 |
-
|
| 117 |
-
// Go back to dashboard
|
| 118 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 119 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 120 |
-
|
| 121 |
-
// Open sidebar
|
| 122 |
-
const hamburgerButton = page.locator('button').filter({ has: page.locator('svg') }).first();
|
| 123 |
-
await hamburgerButton.click();
|
| 124 |
-
await expect(page.getByRole('heading', { name: /對話記錄/i })).toBeVisible({ timeout: 5_000 });
|
| 125 |
-
|
| 126 |
-
// Verify coach conversation IS in the conversation list (with coach emoji)
|
| 127 |
-
const coachConversations = page.locator('.space-y-1 > div.relative.group').filter({ has: page.locator('text=👨🏫') });
|
| 128 |
-
await expect(coachConversations.first()).toBeVisible({ timeout: 10000 });
|
| 129 |
-
|
| 130 |
-
// Close sidebar by clicking overlay
|
| 131 |
-
await page.click('.fixed.inset-0.bg-black\\/50');
|
| 132 |
-
|
| 133 |
-
// Verify sidebar is closed (overlay should be gone)
|
| 134 |
-
await expect(page.locator('.fixed.inset-0.bg-black\\/50')).not.toBeVisible();
|
| 135 |
-
|
| 136 |
-
// Verify we can create a new coach conversation via "教練回顧" button
|
| 137 |
-
await page.click('h3:has-text("教練回顧")');
|
| 138 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 139 |
-
await page.click('button:has-text("開始諮詢")');
|
| 140 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 141 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 142 |
-
|
| 143 |
-
console.log('✅ Coach conversation filtering test passed!');
|
| 144 |
-
});
|
| 145 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-chat-ui-only.spec.ts
DELETED
|
@@ -1,248 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
test.describe('Coach Chat UI Implementation', () => {
|
| 15 |
-
test('should navigate to coach chat page and show correct UI', async ({ page }) => {
|
| 16 |
-
test.setTimeout(60_000);
|
| 17 |
-
const testUser = `coach_ui_${Date.now()}`;
|
| 18 |
-
|
| 19 |
-
// Register and login
|
| 20 |
-
await registerAndLogin(page, testUser);
|
| 21 |
-
|
| 22 |
-
// Click coach consultation card to open modal
|
| 23 |
-
await page.click('h3:has-text("教練回顧")');
|
| 24 |
-
|
| 25 |
-
// Wait for modal to appear
|
| 26 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 27 |
-
|
| 28 |
-
// Click "開始諮詢" button in modal
|
| 29 |
-
await page.click('button:has-text("開始諮詢")');
|
| 30 |
-
|
| 31 |
-
// Wait for conversation page to load (coach conversations now use regular conversation page)
|
| 32 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 33 |
-
|
| 34 |
-
// Verify page loaded with h1 heading
|
| 35 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 36 |
-
|
| 37 |
-
// Verify header has gradient (conversation page uses blue-to-purple gradient)
|
| 38 |
-
await expect(page.locator('.bg-gradient-to-r.from-blue-600.to-purple-600')).toBeVisible();
|
| 39 |
-
|
| 40 |
-
// Verify input field exists
|
| 41 |
-
const inputField = page.locator('input[type="text"]');
|
| 42 |
-
await expect(inputField).toBeVisible();
|
| 43 |
-
|
| 44 |
-
// Verify submit button exists
|
| 45 |
-
const submitButton = page.locator('button[type="submit"]');
|
| 46 |
-
await expect(submitButton).toBeVisible();
|
| 47 |
-
|
| 48 |
-
console.log('✅ Coach chat page UI structure verified');
|
| 49 |
-
});
|
| 50 |
-
|
| 51 |
-
test('should create coach-direct conversation on page load', async ({ page }) => {
|
| 52 |
-
test.setTimeout(60_000);
|
| 53 |
-
const testUser = `coach_conv_${Date.now()}`;
|
| 54 |
-
|
| 55 |
-
// Track network requests
|
| 56 |
-
const createConvRequests: any[] = [];
|
| 57 |
-
const getConvRequests: any[] = [];
|
| 58 |
-
|
| 59 |
-
page.on('request', request => {
|
| 60 |
-
const url = request.url();
|
| 61 |
-
if (url.includes('/api/conversations/create')) {
|
| 62 |
-
createConvRequests.push({
|
| 63 |
-
url: url,
|
| 64 |
-
method: request.method(),
|
| 65 |
-
postData: request.postData()
|
| 66 |
-
});
|
| 67 |
-
}
|
| 68 |
-
if (url.includes('/api/conversations') && request.method() === 'GET') {
|
| 69 |
-
getConvRequests.push(url);
|
| 70 |
-
}
|
| 71 |
-
});
|
| 72 |
-
|
| 73 |
-
// Register and login
|
| 74 |
-
await registerAndLogin(page, testUser);
|
| 75 |
-
|
| 76 |
-
// Navigate to coach chat (via modal flow)
|
| 77 |
-
await page.click('h3:has-text("教練回顧")');
|
| 78 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 79 |
-
await page.click('button:has-text("開始諮詢")');
|
| 80 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 81 |
-
|
| 82 |
-
// Wait for page to load (don't wait for networkidle due to polling)
|
| 83 |
-
await page.waitForSelector('input[type="text"]', { timeout: 10_000 });
|
| 84 |
-
|
| 85 |
-
// Wait for conversation list to be fetched (poll until at least one request)
|
| 86 |
-
for (let i = 0; i < 20 && getConvRequests.length === 0; i++) {
|
| 87 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
// Verify conversation list was fetched
|
| 91 |
-
expect(getConvRequests.length).toBeGreaterThan(0);
|
| 92 |
-
console.log(`✅ Conversations fetched ${getConvRequests.length} times`);
|
| 93 |
-
|
| 94 |
-
// Verify conversation was created (either new or found existing)
|
| 95 |
-
// The page should call GET /api/conversations to find existing coach-direct conversation
|
| 96 |
-
// OR call POST /api/conversations/create if none exists
|
| 97 |
-
const hasCreateCall = createConvRequests.length > 0;
|
| 98 |
-
console.log(`Coach conversation creation calls: ${createConvRequests.length}`);
|
| 99 |
-
|
| 100 |
-
if (hasCreateCall) {
|
| 101 |
-
const createRequest = createConvRequests[0];
|
| 102 |
-
console.log('Create request data:', createRequest.postData);
|
| 103 |
-
|
| 104 |
-
// Verify it's creating a coach_direct conversation
|
| 105 |
-
if (createRequest.postData) {
|
| 106 |
-
const data = JSON.parse(createRequest.postData);
|
| 107 |
-
expect(data.studentPromptId).toBe('coach_direct');
|
| 108 |
-
expect(data.coachPromptId).toBe('satir');
|
| 109 |
-
console.log('✅ Coach-direct conversation created correctly');
|
| 110 |
-
}
|
| 111 |
-
} else {
|
| 112 |
-
console.log('✅ Using existing coach-direct conversation');
|
| 113 |
-
}
|
| 114 |
-
});
|
| 115 |
-
|
| 116 |
-
test('should reload page and maintain conversation', async ({ page }) => {
|
| 117 |
-
test.setTimeout(60_000);
|
| 118 |
-
const testUser = `coach_reload_${Date.now()}`;
|
| 119 |
-
|
| 120 |
-
let conversationId: string | null = null;
|
| 121 |
-
const messageRequests: string[] = [];
|
| 122 |
-
|
| 123 |
-
// Track API calls
|
| 124 |
-
page.on('response', async response => {
|
| 125 |
-
const url = response.url();
|
| 126 |
-
|
| 127 |
-
// Capture conversation ID from create response
|
| 128 |
-
if (url.includes('/api/conversations/create')) {
|
| 129 |
-
try {
|
| 130 |
-
const data = await response.json();
|
| 131 |
-
if (data.conversation?.id) {
|
| 132 |
-
conversationId = data.conversation.id;
|
| 133 |
-
console.log('Captured conversation ID:', conversationId);
|
| 134 |
-
}
|
| 135 |
-
} catch (e) {
|
| 136 |
-
// Ignore JSON parse errors
|
| 137 |
-
}
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
// Track message endpoint calls
|
| 141 |
-
if (url.includes('/messages') && response.request().method() === 'GET') {
|
| 142 |
-
messageRequests.push(url);
|
| 143 |
-
}
|
| 144 |
-
});
|
| 145 |
-
|
| 146 |
-
// Register and login
|
| 147 |
-
await registerAndLogin(page, testUser);
|
| 148 |
-
|
| 149 |
-
// Navigate to coach chat (via modal flow)
|
| 150 |
-
await page.click('h3:has-text("教練回顧")');
|
| 151 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 152 |
-
await page.click('button:has-text("開始諮詢")', { force: true });
|
| 153 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 154 |
-
|
| 155 |
-
// Wait for conversation creation (don't wait for networkidle due to polling)
|
| 156 |
-
await page.waitForSelector('input[type="text"]', { timeout: 10_000 });
|
| 157 |
-
|
| 158 |
-
// Wait for initial message requests (poll until at least one)
|
| 159 |
-
for (let i = 0; i < 20 && messageRequests.length === 0; i++) {
|
| 160 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
// Get initial message requests count
|
| 164 |
-
const initialMessageCalls = messageRequests.length;
|
| 165 |
-
console.log(`Initial message endpoint calls: ${initialMessageCalls}`);
|
| 166 |
-
|
| 167 |
-
// Reload the page
|
| 168 |
-
await page.reload();
|
| 169 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 170 |
-
|
| 171 |
-
// Wait for reload to complete (don't wait for networkidle due to polling)
|
| 172 |
-
await page.waitForSelector('input[type="text"]', { timeout: 10_000 });
|
| 173 |
-
|
| 174 |
-
// Wait for message requests after reload (poll with timeout)
|
| 175 |
-
const beforeCount = initialMessageCalls;
|
| 176 |
-
for (let i = 0; i < 20 && messageRequests.length === beforeCount; i++) {
|
| 177 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
// Verify messages endpoint was called (conversation maintained)
|
| 181 |
-
const afterReloadMessageCalls = messageRequests.length;
|
| 182 |
-
console.log(`After reload message endpoint calls: ${afterReloadMessageCalls}`);
|
| 183 |
-
|
| 184 |
-
// Just verify that messages endpoint was called at least once (conversation persisted)
|
| 185 |
-
expect(afterReloadMessageCalls).toBeGreaterThanOrEqual(1);
|
| 186 |
-
|
| 187 |
-
// Verify we're still on the same conversation page
|
| 188 |
-
await expect(page).toHaveURL(/\/conversation\//);
|
| 189 |
-
await expect(page.getByText(/薩提爾教練/i)).toBeVisible();
|
| 190 |
-
|
| 191 |
-
console.log('✅ Page reload maintained conversation and loaded messages');
|
| 192 |
-
});
|
| 193 |
-
|
| 194 |
-
test('should not show coach-direct conversation in sidebar', async ({ page }) => {
|
| 195 |
-
test.setTimeout(60_000);
|
| 196 |
-
const testUser = `coach_sidebar_${Date.now()}`;
|
| 197 |
-
|
| 198 |
-
// Register and login
|
| 199 |
-
await registerAndLogin(page, testUser);
|
| 200 |
-
|
| 201 |
-
// Navigate to coach chat to create coach-direct conversation (via modal)
|
| 202 |
-
await page.click('h3:has-text("教練回顧")');
|
| 203 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 204 |
-
await page.click('button:has-text("開始諮詢")');
|
| 205 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 206 |
-
await page.waitForSelector('input[type="text"]', { timeout: 10_000 });
|
| 207 |
-
|
| 208 |
-
// Go back to dashboard
|
| 209 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 210 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 211 |
-
|
| 212 |
-
// Open sidebar (click hamburger menu)
|
| 213 |
-
const hamburgerButton = page.locator('button').filter({ has: page.locator('svg') }).first();
|
| 214 |
-
await hamburgerButton.click();
|
| 215 |
-
|
| 216 |
-
// Wait for sidebar to appear
|
| 217 |
-
await expect(page.getByRole('heading', { name: /對話記錄/i })).toBeVisible({ timeout: 5_000 });
|
| 218 |
-
|
| 219 |
-
// Verify coach conversation IS in the list (with coach emoji)
|
| 220 |
-
const coachConversations = page.locator('.space-y-1 > div.relative.group').filter({ has: page.locator('text=👨🏫') });
|
| 221 |
-
await expect(coachConversations.first()).toBeVisible({ timeout: 10000 });
|
| 222 |
-
|
| 223 |
-
console.log('✅ Coach conversation correctly shown in sidebar');
|
| 224 |
-
});
|
| 225 |
-
|
| 226 |
-
test('should show correct coach avatar and styling', async ({ page }) => {
|
| 227 |
-
test.setTimeout(60_000);
|
| 228 |
-
const testUser = `coach_style_${Date.now()}`;
|
| 229 |
-
|
| 230 |
-
// Register and login
|
| 231 |
-
await registerAndLogin(page, testUser);
|
| 232 |
-
|
| 233 |
-
// Navigate to coach chat (via modal flow)
|
| 234 |
-
await page.click('h3:has-text("教練回顧")');
|
| 235 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 236 |
-
await page.click('button:has-text("開始諮詢")');
|
| 237 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 238 |
-
|
| 239 |
-
// Verify header has correct gradient (conversation page uses blue-to-purple)
|
| 240 |
-
const header = page.locator('.bg-gradient-to-r.from-blue-600.to-purple-600');
|
| 241 |
-
await expect(header).toBeVisible();
|
| 242 |
-
|
| 243 |
-
// Verify conversation page loaded correctly
|
| 244 |
-
await expect(page.locator('h1')).toBeVisible();
|
| 245 |
-
|
| 246 |
-
console.log('✅ Coach conversation page styling correct');
|
| 247 |
-
});
|
| 248 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-conversation-messaging.spec.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
test.describe('Coach Conversation Messaging', () => {
|
| 15 |
-
test.use({ workers: 1 }); // Run serially to avoid rate limits
|
| 16 |
-
|
| 17 |
-
test('should send messages in coach conversation without 500 errors', async ({ page }) => {
|
| 18 |
-
test.setTimeout(90_000); // 90 seconds for LLM responses
|
| 19 |
-
const testUser = `coach-msg-test-${Date.now()}`;
|
| 20 |
-
|
| 21 |
-
// Step 1: Register and login
|
| 22 |
-
await registerAndLogin(page, testUser);
|
| 23 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 24 |
-
|
| 25 |
-
console.log('✅ Logged in successfully');
|
| 26 |
-
|
| 27 |
-
// Step 2: Create a coach conversation via dashboard
|
| 28 |
-
await page.click('h3:has-text("教練回顧")');
|
| 29 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 30 |
-
await page.click('button:has-text("開始諮詢")');
|
| 31 |
-
|
| 32 |
-
// Wait for conversation page to load
|
| 33 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 34 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 35 |
-
|
| 36 |
-
console.log('✅ Coach conversation created and loaded');
|
| 37 |
-
|
| 38 |
-
// Step 3: Send first message
|
| 39 |
-
const firstMessage = 'Hello coach, I need help with teaching ADHD students.';
|
| 40 |
-
const inputField = page.locator('input[type="text"]');
|
| 41 |
-
await expect(inputField).toBeVisible({ timeout: 5000 });
|
| 42 |
-
await inputField.fill(firstMessage);
|
| 43 |
-
|
| 44 |
-
// Submit via Enter key
|
| 45 |
-
await inputField.press('Enter');
|
| 46 |
-
|
| 47 |
-
// Wait for coach response (proves message was sent)
|
| 48 |
-
const coachResponse = page.locator('.bg-gradient-to-br.from-purple-50 .whitespace-pre-wrap').first();
|
| 49 |
-
await expect(coachResponse).toBeVisible({ timeout: 60000 });
|
| 50 |
-
|
| 51 |
-
console.log('✅ Received coach response');
|
| 52 |
-
|
| 53 |
-
// Step 4: Navigate back to dashboard
|
| 54 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 55 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 56 |
-
|
| 57 |
-
console.log('✅ Navigated back to dashboard');
|
| 58 |
-
|
| 59 |
-
// Step 5: Open sidebar and click on the coach conversation
|
| 60 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 61 |
-
await expect(page.locator('text=對話記錄')).toBeVisible();
|
| 62 |
-
|
| 63 |
-
// Find and click the coach conversation (has coach emoji in sidebar)
|
| 64 |
-
const coachConv = page.locator('.space-y-1 > div').filter({ hasText: '👨🏫' }).first();
|
| 65 |
-
await expect(coachConv).toBeVisible();
|
| 66 |
-
await coachConv.click();
|
| 67 |
-
|
| 68 |
-
// Wait for conversation page to load
|
| 69 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 70 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 71 |
-
|
| 72 |
-
console.log('✅ Re-opened coach conversation from history');
|
| 73 |
-
|
| 74 |
-
// Step 6: Verify first message is still there
|
| 75 |
-
await expect(page.getByText(firstMessage)).toBeVisible();
|
| 76 |
-
|
| 77 |
-
console.log('✅ Message history persisted');
|
| 78 |
-
|
| 79 |
-
// Step 7: Send another message
|
| 80 |
-
const secondMessage = 'Can you give me specific strategies?';
|
| 81 |
-
|
| 82 |
-
// Close Next.js Dev Tools if open (can block form submission)
|
| 83 |
-
const closeDevTools = page.locator('button:has-text("Close Next.js Dev Tools")');
|
| 84 |
-
if (await closeDevTools.isVisible()) {
|
| 85 |
-
await closeDevTools.click();
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
// Wait for form to be fully ready (input enabled, not in loading state)
|
| 89 |
-
const inputField2 = page.locator('input[type="text"]#chat-input');
|
| 90 |
-
await expect(inputField2).toBeVisible({ timeout: 5000 });
|
| 91 |
-
await expect(inputField2).toBeEnabled({ timeout: 5000 });
|
| 92 |
-
|
| 93 |
-
// Fill and submit second message via Enter key (more reliable than button)
|
| 94 |
-
await inputField2.click(); // Focus the input
|
| 95 |
-
await inputField2.fill(secondMessage);
|
| 96 |
-
await inputField2.press('Enter');
|
| 97 |
-
|
| 98 |
-
// Wait for second message to appear first (proves it was sent)
|
| 99 |
-
await expect(page.getByText(secondMessage)).toBeVisible({ timeout: 10000 });
|
| 100 |
-
|
| 101 |
-
// Wait for second coach response (look for any coach response after second message)
|
| 102 |
-
// Since responses might be on same page, just verify we have at least one response
|
| 103 |
-
const coachResponses = page.locator('.bg-gradient-to-br.from-purple-50');
|
| 104 |
-
await expect(coachResponses.first()).toBeVisible({ timeout: 60000 });
|
| 105 |
-
|
| 106 |
-
console.log('✅ Received second coach response');
|
| 107 |
-
|
| 108 |
-
console.log('✅ Both messages visible in conversation');
|
| 109 |
-
|
| 110 |
-
// Step 8: Verify no network errors (check for 500 errors in console)
|
| 111 |
-
const errors = [];
|
| 112 |
-
page.on('console', msg => {
|
| 113 |
-
if (msg.type() === 'error') {
|
| 114 |
-
errors.push(msg.text());
|
| 115 |
-
}
|
| 116 |
-
});
|
| 117 |
-
|
| 118 |
-
// Verify no 500 errors occurred
|
| 119 |
-
const has500Error = errors.some(err => err.includes('500'));
|
| 120 |
-
expect(has500Error).toBe(false);
|
| 121 |
-
|
| 122 |
-
console.log('✅✅✅ Coach conversation messaging test passed!');
|
| 123 |
-
});
|
| 124 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-conversation-speaker-default.spec.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
// Helper to create a conversation via API
|
| 15 |
-
async function createConversationViaAPI(
|
| 16 |
-
username: string,
|
| 17 |
-
studentPromptId: string,
|
| 18 |
-
coachPromptId: string = 'satir'
|
| 19 |
-
) {
|
| 20 |
-
const authHeader = Buffer.from(`${username}:cz-2025`).toString('base64');
|
| 21 |
-
const response = await fetch(`${baseURL}/api/conversations/create`, {
|
| 22 |
-
method: 'POST',
|
| 23 |
-
headers: {
|
| 24 |
-
'Content-Type': 'application/json',
|
| 25 |
-
'Authorization': `Basic ${authHeader}`,
|
| 26 |
-
},
|
| 27 |
-
body: JSON.stringify({
|
| 28 |
-
studentPromptId,
|
| 29 |
-
coachPromptId,
|
| 30 |
-
include3ConversationSummary: false,
|
| 31 |
-
}),
|
| 32 |
-
});
|
| 33 |
-
|
| 34 |
-
if (!response.ok) {
|
| 35 |
-
throw new Error(`Failed to create conversation: ${response.status}`);
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
const data = await response.json();
|
| 39 |
-
return data.conversation.id;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
// SKIPPED: Speaker toggle buttons are hidden in UI
|
| 43 |
-
test.describe.skip('Coach Conversation Speaker Default', () => {
|
| 44 |
-
test.use({ workers: 1 }); // Run serially to avoid rate limits
|
| 45 |
-
|
| 46 |
-
test('should default to coach speaker when opening coach_direct conversation', async ({ page }) =>{
|
| 47 |
-
test.setTimeout(60_000);
|
| 48 |
-
const testUser = `coach-speaker-test-${Date.now()}`;
|
| 49 |
-
|
| 50 |
-
// Step 1: Register and login
|
| 51 |
-
await registerAndLogin(page, testUser);
|
| 52 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 53 |
-
|
| 54 |
-
console.log('✅ Logged in successfully');
|
| 55 |
-
|
| 56 |
-
// Step 2: Create coach_direct conversation via API
|
| 57 |
-
const coachConvId = await createConversationViaAPI(testUser, 'coach_direct', 'satir');
|
| 58 |
-
console.log(`✅ Created coach conversation: ${coachConvId}`);
|
| 59 |
-
|
| 60 |
-
// Reload page to get fresh conversation list
|
| 61 |
-
await page.reload();
|
| 62 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 63 |
-
|
| 64 |
-
// Step 3: Navigate to conversation page by clicking from conversation list
|
| 65 |
-
await page.click('h3:has-text("自訂對話演練")');
|
| 66 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible();
|
| 67 |
-
|
| 68 |
-
// Find and click the coach conversation (contains "諮詢" in title)
|
| 69 |
-
const coachConv = page.locator('.space-y-3 > div').filter({ hasText: '諮詢' }).first();
|
| 70 |
-
await expect(coachConv).toBeVisible();
|
| 71 |
-
await coachConv.click();
|
| 72 |
-
|
| 73 |
-
// Wait for conversation page to load
|
| 74 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 75 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 76 |
-
|
| 77 |
-
console.log('✅ Navigated to coach conversation');
|
| 78 |
-
|
| 79 |
-
// Step 4: Verify empty state shows correct text for coach conversation
|
| 80 |
-
await expect(page.locator('h3:has-text("開始與教練對話")')).toBeVisible();
|
| 81 |
-
await expect(page.locator('text=直接諮詢教練專業建議')).toBeVisible();
|
| 82 |
-
|
| 83 |
-
console.log('✅ Empty state shows correct coach text');
|
| 84 |
-
|
| 85 |
-
// Step 5: Verify coach button (👨🏫) is active by default
|
| 86 |
-
const coachButton = page.locator('button[title="詢問教練"]');
|
| 87 |
-
await expect(coachButton).toHaveClass(/from-purple-400/); // Should have gradient background
|
| 88 |
-
|
| 89 |
-
console.log('✅ Coach button is active by default');
|
| 90 |
-
|
| 91 |
-
// Step 6: Verify student button doesn't exist in coach_direct conversation
|
| 92 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 93 |
-
await expect(studentButton).not.toBeVisible(); // Should not exist
|
| 94 |
-
|
| 95 |
-
console.log('✅ Student button is not present (as expected for coach_direct)');
|
| 96 |
-
|
| 97 |
-
// Step 7: Verify input placeholder mentions coach
|
| 98 |
-
const inputField = page.locator('input[type="text"]#chat-input');
|
| 99 |
-
const placeholder = await inputField.getAttribute('placeholder');
|
| 100 |
-
expect(placeholder).toContain('陳老師'); // Should mention coach name
|
| 101 |
-
expect(placeholder).toContain('建議'); // Should say "建議" not "對話"
|
| 102 |
-
|
| 103 |
-
console.log('✅ Input placeholder shows coach context');
|
| 104 |
-
|
| 105 |
-
console.log('✅✅✅ Coach conversation speaker default test passed!');
|
| 106 |
-
});
|
| 107 |
-
|
| 108 |
-
test('should default to student speaker when opening student conversation', async ({ page }) => {
|
| 109 |
-
test.setTimeout(60_000);
|
| 110 |
-
const testUser = `student-speaker-test-${Date.now()}`;
|
| 111 |
-
|
| 112 |
-
// Step 1: Register and login
|
| 113 |
-
await registerAndLogin(page, testUser);
|
| 114 |
-
|
| 115 |
-
console.log('✅ Logged in');
|
| 116 |
-
|
| 117 |
-
// Step 2: Create student conversation via API
|
| 118 |
-
const studentConvId = await createConversationViaAPI(testUser, 'ruirui', 'satir');
|
| 119 |
-
console.log(`✅ Created student conversation: ${studentConvId}`);
|
| 120 |
-
|
| 121 |
-
// Step 3: Navigate directly to conversation page (simulates clicking from list)
|
| 122 |
-
await page.goto(`${baseURL}/conversation/${studentConvId}`);
|
| 123 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 124 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 125 |
-
|
| 126 |
-
console.log('✅ Navigated to student conversation');
|
| 127 |
-
|
| 128 |
-
// Step 4: Wait for page to fully load
|
| 129 |
-
await page.waitForTimeout(1000);
|
| 130 |
-
|
| 131 |
-
// Step 5: Verify student button (🎓) is active by default
|
| 132 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 133 |
-
await expect(studentButton).toHaveClass(/from-blue-400/); // Should have blue gradient
|
| 134 |
-
|
| 135 |
-
console.log('✅ Student button is active by default');
|
| 136 |
-
|
| 137 |
-
// Step 6: Verify coach button (👨🏫) is inactive
|
| 138 |
-
const coachButton = page.locator('button[title="詢問教練"]');
|
| 139 |
-
await expect(coachButton).toHaveClass(/bg-gray-200/); // Should have gray background
|
| 140 |
-
|
| 141 |
-
console.log('✅ Coach button is inactive');
|
| 142 |
-
|
| 143 |
-
// Step 7: Verify input placeholder mentions student
|
| 144 |
-
const inputField = page.locator('input[type="text"]#chat-input');
|
| 145 |
-
const placeholder = await inputField.getAttribute('placeholder');
|
| 146 |
-
expect(placeholder).toContain('睿睿'); // Should mention student name
|
| 147 |
-
expect(placeholder).toContain('對話'); // Should say "對話" not "建議"
|
| 148 |
-
|
| 149 |
-
console.log('✅ Input placeholder shows student context');
|
| 150 |
-
|
| 151 |
-
console.log('✅✅✅ Student conversation speaker default test passed!');
|
| 152 |
-
});
|
| 153 |
-
|
| 154 |
-
test('should send message with correct speaker after navigating from list', async ({ page }) => {
|
| 155 |
-
test.setTimeout(90_000);
|
| 156 |
-
const testUser = `speaker-message-test-${Date.now()}`;
|
| 157 |
-
|
| 158 |
-
// Register and login
|
| 159 |
-
await registerAndLogin(page, testUser);
|
| 160 |
-
|
| 161 |
-
// Create coach conversation
|
| 162 |
-
const coachConvId = await createConversationViaAPI(testUser, 'coach_direct', 'satir');
|
| 163 |
-
console.log(`✅ Created coach conversation: ${coachConvId}`);
|
| 164 |
-
|
| 165 |
-
// Navigate directly to conversation (simulates clicking from list)
|
| 166 |
-
await page.goto(`${baseURL}/conversation/${coachConvId}`);
|
| 167 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 168 |
-
await page.waitForTimeout(1000);
|
| 169 |
-
|
| 170 |
-
console.log('✅ Opened coach conversation');
|
| 171 |
-
|
| 172 |
-
// Send a message (should use coach speaker by default)
|
| 173 |
-
const message = 'I need help with classroom management';
|
| 174 |
-
const inputField = page.locator('input[type="text"]#chat-input');
|
| 175 |
-
await inputField.fill(message);
|
| 176 |
-
|
| 177 |
-
// Submit via Enter key (more reliable than clicking button)
|
| 178 |
-
await inputField.press('Enter');
|
| 179 |
-
|
| 180 |
-
// Wait for coach response (proves message was sent)
|
| 181 |
-
const coachResponse = page.locator('.bg-gradient-to-br.from-purple-50').first();
|
| 182 |
-
await expect(coachResponse).toBeVisible({ timeout: 60000 });
|
| 183 |
-
|
| 184 |
-
console.log('✅ Received coach response');
|
| 185 |
-
|
| 186 |
-
console.log('✅✅✅ Message with correct speaker test passed!');
|
| 187 |
-
});
|
| 188 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-conversation-ui-buttons.spec.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
// Helper to create a conversation via API
|
| 15 |
-
async function createConversationViaAPI(
|
| 16 |
-
username: string,
|
| 17 |
-
studentPromptId: string,
|
| 18 |
-
coachPromptId: string = 'satir'
|
| 19 |
-
) {
|
| 20 |
-
const authHeader = Buffer.from(`${username}:cz-2025`).toString('base64');
|
| 21 |
-
const response = await fetch(`${baseURL}/api/conversations/create`, {
|
| 22 |
-
method: 'POST',
|
| 23 |
-
headers: {
|
| 24 |
-
'Content-Type': 'application/json',
|
| 25 |
-
'Authorization': `Basic ${authHeader}`,
|
| 26 |
-
},
|
| 27 |
-
body: JSON.stringify({
|
| 28 |
-
studentPromptId,
|
| 29 |
-
coachPromptId,
|
| 30 |
-
include3ConversationSummary: false,
|
| 31 |
-
}),
|
| 32 |
-
});
|
| 33 |
-
|
| 34 |
-
if (!response.ok) {
|
| 35 |
-
throw new Error(`Failed to create conversation: ${response.status}`);
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
const data = await response.json();
|
| 39 |
-
return data.conversation.id;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
// SKIPPED: Speaker toggle buttons are hidden in UI
|
| 43 |
-
test.describe.skip('Coach Conversation UI Buttons', () => {
|
| 44 |
-
test.use({ workers: 1 }); // Run serially to avoid rate limits
|
| 45 |
-
|
| 46 |
-
test('should hide student button in coach_direct conversation', async ({ page }) => {
|
| 47 |
-
test.setTimeout(60_000);
|
| 48 |
-
const testUser = `coach-ui-btn-test-${Date.now()}`;
|
| 49 |
-
|
| 50 |
-
// Step 1: Register and login
|
| 51 |
-
await registerAndLogin(page, testUser);
|
| 52 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 53 |
-
|
| 54 |
-
console.log('✅ Logged in successfully');
|
| 55 |
-
|
| 56 |
-
// Step 2: Create coach_direct conversation via API
|
| 57 |
-
const coachConvId = await createConversationViaAPI(testUser, 'coach_direct', 'satir');
|
| 58 |
-
console.log(`✅ Created coach conversation: ${coachConvId}`);
|
| 59 |
-
|
| 60 |
-
// Step 3: Navigate to conversation page
|
| 61 |
-
await page.goto(`${baseURL}/conversation/${coachConvId}`);
|
| 62 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 63 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 64 |
-
|
| 65 |
-
console.log('✅ Navigated to coach conversation');
|
| 66 |
-
|
| 67 |
-
// Step 4: Verify coach button (👨🏫) is visible and active
|
| 68 |
-
const coachButton = page.locator('button[title="詢問教練"]');
|
| 69 |
-
await expect(coachButton).toBeVisible();
|
| 70 |
-
await expect(coachButton).toHaveClass(/from-purple-400/); // Should have gradient (active)
|
| 71 |
-
|
| 72 |
-
console.log('✅ Coach button is visible and active');
|
| 73 |
-
|
| 74 |
-
// Step 5: Verify student button (🎓) is NOT in the DOM
|
| 75 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 76 |
-
await expect(studentButton).toHaveCount(0); // Should not exist in DOM
|
| 77 |
-
|
| 78 |
-
console.log('✅ Student button is hidden (not in DOM)');
|
| 79 |
-
|
| 80 |
-
// Step 6: Verify only one speaker button exists
|
| 81 |
-
const speakerButtons = page.locator('button[title*="對話"], button[title*="教練"]');
|
| 82 |
-
const buttonCount = await speakerButtons.count();
|
| 83 |
-
expect(buttonCount).toBe(1); // Only coach button
|
| 84 |
-
|
| 85 |
-
console.log('✅ Only one speaker button exists (coach)');
|
| 86 |
-
|
| 87 |
-
console.log('✅✅✅ Coach conversation UI button test passed!');
|
| 88 |
-
});
|
| 89 |
-
|
| 90 |
-
test('should show both buttons in student conversation', async ({ page }) => {
|
| 91 |
-
test.setTimeout(60_000);
|
| 92 |
-
const testUser = `student-ui-btn-test-${Date.now()}`;
|
| 93 |
-
|
| 94 |
-
// Step 1: Register and login
|
| 95 |
-
await registerAndLogin(page, testUser);
|
| 96 |
-
|
| 97 |
-
console.log('✅ Logged in');
|
| 98 |
-
|
| 99 |
-
// Step 2: Create student conversation via API
|
| 100 |
-
const studentConvId = await createConversationViaAPI(testUser, 'ruirui', 'satir');
|
| 101 |
-
console.log(`✅ Created student conversation: ${studentConvId}`);
|
| 102 |
-
|
| 103 |
-
// Step 3: Navigate to conversation page
|
| 104 |
-
await page.goto(`${baseURL}/conversation/${studentConvId}`);
|
| 105 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 106 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 107 |
-
|
| 108 |
-
console.log('✅ Navigated to student conversation');
|
| 109 |
-
|
| 110 |
-
// Step 4: Verify both buttons are visible
|
| 111 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 112 |
-
const coachButton = page.locator('button[title="詢問教練"]');
|
| 113 |
-
|
| 114 |
-
await expect(studentButton).toBeVisible();
|
| 115 |
-
await expect(coachButton).toBeVisible();
|
| 116 |
-
|
| 117 |
-
console.log('✅ Both student and coach buttons are visible');
|
| 118 |
-
|
| 119 |
-
// Step 5: Verify student button is active by default
|
| 120 |
-
await expect(studentButton).toHaveClass(/from-blue-400/); // Should have blue gradient (active)
|
| 121 |
-
|
| 122 |
-
console.log('✅ Student button is active by default');
|
| 123 |
-
|
| 124 |
-
// Step 6: Verify coach button is inactive
|
| 125 |
-
await expect(coachButton).toHaveClass(/bg-gray-200/); // Should have gray background (inactive)
|
| 126 |
-
|
| 127 |
-
console.log('✅ Coach button is inactive');
|
| 128 |
-
|
| 129 |
-
// Step 7: Verify exactly two speaker buttons exist
|
| 130 |
-
const speakerButtons = page.locator('button[title*="對話"], button[title*="教練"]');
|
| 131 |
-
const buttonCount = await speakerButtons.count();
|
| 132 |
-
expect(buttonCount).toBe(2); // Student + Coach buttons
|
| 133 |
-
|
| 134 |
-
console.log('✅ Two speaker buttons exist (student + coach)');
|
| 135 |
-
|
| 136 |
-
console.log('✅✅✅ Student conversation UI button test passed!');
|
| 137 |
-
});
|
| 138 |
-
|
| 139 |
-
test('should prevent switching to student speaker in coach conversation', async ({ page }) => {
|
| 140 |
-
test.setTimeout(90_000);
|
| 141 |
-
const testUser = `coach-speaker-prevent-test-${Date.now()}`;
|
| 142 |
-
|
| 143 |
-
// Register and login
|
| 144 |
-
await registerAndLogin(page, testUser);
|
| 145 |
-
|
| 146 |
-
// Create coach conversation
|
| 147 |
-
const coachConvId = await createConversationViaAPI(testUser, 'coach_direct', 'satir');
|
| 148 |
-
console.log(`✅ Created coach conversation: ${coachConvId}`);
|
| 149 |
-
|
| 150 |
-
// Navigate to conversation
|
| 151 |
-
await page.goto(`${baseURL}/conversation/${coachConvId}`);
|
| 152 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 153 |
-
await page.waitForTimeout(1000);
|
| 154 |
-
|
| 155 |
-
console.log('✅ Opened coach conversation');
|
| 156 |
-
|
| 157 |
-
// Verify student button does not exist
|
| 158 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 159 |
-
await expect(studentButton).toHaveCount(0);
|
| 160 |
-
|
| 161 |
-
console.log('✅ No student button available');
|
| 162 |
-
|
| 163 |
-
// Send a message (will use coach speaker automatically)
|
| 164 |
-
const message = 'Help with classroom behavior management';
|
| 165 |
-
const inputField = page.locator('input[type="text"]#chat-input');
|
| 166 |
-
await inputField.fill(message);
|
| 167 |
-
|
| 168 |
-
// Submit via Enter key (more reliable than clicking button)
|
| 169 |
-
await inputField.press('Enter');
|
| 170 |
-
|
| 171 |
-
// Wait for coach response (proves message was sent)
|
| 172 |
-
const coachResponse = page.locator('.bg-gradient-to-br.from-purple-50').first();
|
| 173 |
-
await expect(coachResponse).toBeVisible({ timeout: 60000 });
|
| 174 |
-
|
| 175 |
-
console.log('✅ Received coach response (purple gradient)');
|
| 176 |
-
|
| 177 |
-
// Verify there's still no way to switch to student
|
| 178 |
-
await expect(studentButton).toHaveCount(0);
|
| 179 |
-
|
| 180 |
-
console.log('✅ Student button still not available after message');
|
| 181 |
-
|
| 182 |
-
console.log('✅✅✅ Coach speaker prevention test passed!');
|
| 183 |
-
});
|
| 184 |
-
|
| 185 |
-
test('should allow switching speakers in student conversation', async ({ page }) => {
|
| 186 |
-
test.setTimeout(90_000);
|
| 187 |
-
const testUser = `student-switch-test-${Date.now()}`;
|
| 188 |
-
|
| 189 |
-
// Register and login
|
| 190 |
-
await registerAndLogin(page, testUser);
|
| 191 |
-
|
| 192 |
-
// Create student conversation
|
| 193 |
-
const studentConvId = await createConversationViaAPI(testUser, 'ruirui', 'satir');
|
| 194 |
-
console.log(`✅ Created student conversation: ${studentConvId}`);
|
| 195 |
-
|
| 196 |
-
// Navigate to conversation
|
| 197 |
-
await page.goto(`${baseURL}/conversation/${studentConvId}`);
|
| 198 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 199 |
-
await page.waitForTimeout(1000);
|
| 200 |
-
|
| 201 |
-
console.log('✅ Opened student conversation');
|
| 202 |
-
|
| 203 |
-
// Verify both buttons exist
|
| 204 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 205 |
-
const coachButton = page.locator('button[title="詢問教練"]');
|
| 206 |
-
|
| 207 |
-
await expect(studentButton).toBeVisible();
|
| 208 |
-
await expect(coachButton).toBeVisible();
|
| 209 |
-
|
| 210 |
-
// Student button should be active by default
|
| 211 |
-
await expect(studentButton).toHaveClass(/from-blue-400/);
|
| 212 |
-
|
| 213 |
-
console.log('✅ Student button active by default');
|
| 214 |
-
|
| 215 |
-
// Click coach button to switch
|
| 216 |
-
await coachButton.click();
|
| 217 |
-
await page.waitForTimeout(500);
|
| 218 |
-
|
| 219 |
-
// Verify coach button is now active
|
| 220 |
-
await expect(coachButton).toHaveClass(/from-purple-400/);
|
| 221 |
-
await expect(studentButton).toHaveClass(/bg-gray-200/);
|
| 222 |
-
|
| 223 |
-
console.log('✅ Successfully switched to coach speaker');
|
| 224 |
-
|
| 225 |
-
// Remove Next.js dev overlay using JavaScript (it blocks clicks)
|
| 226 |
-
await page.evaluate(() => {
|
| 227 |
-
const overlay = document.querySelector('nextjs-portal');
|
| 228 |
-
if (overlay) overlay.remove();
|
| 229 |
-
});
|
| 230 |
-
await page.waitForTimeout(500);
|
| 231 |
-
|
| 232 |
-
// Click student button to switch back
|
| 233 |
-
await studentButton.click();
|
| 234 |
-
await page.waitForTimeout(500);
|
| 235 |
-
|
| 236 |
-
// Verify student button is active again
|
| 237 |
-
await expect(studentButton).toHaveClass(/from-blue-400/);
|
| 238 |
-
await expect(coachButton).toHaveClass(/bg-gray-200/);
|
| 239 |
-
|
| 240 |
-
console.log('✅ Successfully switched back to student speaker');
|
| 241 |
-
|
| 242 |
-
console.log('✅✅✅ Speaker switching test passed!');
|
| 243 |
-
});
|
| 244 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/coach-guidance-mode.spec.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
/**
|
| 4 |
-
* E2E Test: Coach Guidance Mode
|
| 5 |
-
*
|
| 6 |
-
* Verifies that when a teacher asks the coach for advice during a student conversation,
|
| 7 |
-
* the coach provides meta-advice TO THE TEACHER (using "您") rather than speaking
|
| 8 |
-
* directly to the student.
|
| 9 |
-
*
|
| 10 |
-
* Expected behavior:
|
| 11 |
-
* - Coach should address the teacher (user) with "您"
|
| 12 |
-
* - Coach should provide guidance about HOW to respond to the student
|
| 13 |
-
* - Coach should NOT give advice directly to the student
|
| 14 |
-
*/
|
| 15 |
-
|
| 16 |
-
const API_URL = 'http://localhost:3000';
|
| 17 |
-
|
| 18 |
-
test.describe('Coach Guidance Mode', () => {
|
| 19 |
-
let userId: string;
|
| 20 |
-
let conversationId: string;
|
| 21 |
-
let username: string;
|
| 22 |
-
let authHeader: string;
|
| 23 |
-
|
| 24 |
-
test.beforeAll(async ({ request }) => {
|
| 25 |
-
// Use timestamped username to avoid conflicts
|
| 26 |
-
username = `test-coach-${Date.now()}`;
|
| 27 |
-
authHeader = 'Basic ' + Buffer.from(`${username}:cz-2025`).toString('base64');
|
| 28 |
-
|
| 29 |
-
// Register test user
|
| 30 |
-
const registerResponse = await request.post(`${API_URL}/api/auth/register`, {
|
| 31 |
-
headers: {
|
| 32 |
-
'Content-Type': 'application/json',
|
| 33 |
-
},
|
| 34 |
-
data: {
|
| 35 |
-
username: username,
|
| 36 |
-
password: 'cz-2025',
|
| 37 |
-
},
|
| 38 |
-
});
|
| 39 |
-
|
| 40 |
-
expect(registerResponse.ok()).toBeTruthy();
|
| 41 |
-
const registerData = await registerResponse.json();
|
| 42 |
-
userId = registerData.user.id;
|
| 43 |
-
console.log(`[TEST] Created user: ${userId}`);
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
test('should provide meta-advice to teacher, not direct advice to student', async ({ request }) => {
|
| 47 |
-
// Step 1: Create a conversation with a grade 7 student (國一)
|
| 48 |
-
const createConvResponse = await request.post(`${API_URL}/api/conversations/create`, {
|
| 49 |
-
headers: {
|
| 50 |
-
'Content-Type': 'application/json',
|
| 51 |
-
'Authorization': authHeader,
|
| 52 |
-
},
|
| 53 |
-
data: {
|
| 54 |
-
studentPromptId: 'xiaoxu',
|
| 55 |
-
coachPromptId: 'satir',
|
| 56 |
-
},
|
| 57 |
-
});
|
| 58 |
-
|
| 59 |
-
expect(createConvResponse.ok()).toBeTruthy();
|
| 60 |
-
const convData = await createConvResponse.json();
|
| 61 |
-
conversationId = convData.conversation.id;
|
| 62 |
-
console.log(`[TEST] Created conversation: ${conversationId}`);
|
| 63 |
-
|
| 64 |
-
// Step 2: Send a message as teacher to student
|
| 65 |
-
const studentMessageResponse = await request.post(`${API_URL}/api/conversations/${conversationId}/message`, {
|
| 66 |
-
headers: {
|
| 67 |
-
'Content-Type': 'application/json',
|
| 68 |
-
'Authorization': authHeader,
|
| 69 |
-
},
|
| 70 |
-
data: {
|
| 71 |
-
messages: [
|
| 72 |
-
{
|
| 73 |
-
role: 'user',
|
| 74 |
-
parts: [{ type: 'text', text: '有時候有點緊張和害怕,可以怎麼辦?' }],
|
| 75 |
-
metadata: { speaker: 'student' },
|
| 76 |
-
},
|
| 77 |
-
],
|
| 78 |
-
},
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
expect(studentMessageResponse.ok()).toBeTruthy();
|
| 82 |
-
|
| 83 |
-
// Read the streaming response
|
| 84 |
-
const studentResponseText = await studentMessageResponse.text();
|
| 85 |
-
console.log(`[TEST] Student response received (length: ${studentResponseText.length})`);
|
| 86 |
-
|
| 87 |
-
// Step 3: Ask coach for advice (teacher asking coach)
|
| 88 |
-
const coachMessageResponse = await request.post(`${API_URL}/api/conversations/${conversationId}/message`, {
|
| 89 |
-
headers: {
|
| 90 |
-
'Content-Type': 'application/json',
|
| 91 |
-
'Authorization': authHeader,
|
| 92 |
-
},
|
| 93 |
-
data: {
|
| 94 |
-
messages: [
|
| 95 |
-
{
|
| 96 |
-
role: 'user',
|
| 97 |
-
parts: [{ type: 'text', text: '有時候有點緊張和害怕,可以怎麼辦?' }],
|
| 98 |
-
metadata: { speaker: 'student' },
|
| 99 |
-
},
|
| 100 |
-
{
|
| 101 |
-
role: 'assistant',
|
| 102 |
-
parts: [{ type: 'text', text: '學生回應內容' }],
|
| 103 |
-
metadata: { speaker: 'student' },
|
| 104 |
-
},
|
| 105 |
-
{
|
| 106 |
-
role: 'user',
|
| 107 |
-
parts: [{ type: 'text', text: '老師有什麼建議嗎?' }],
|
| 108 |
-
metadata: { speaker: 'coach' },
|
| 109 |
-
},
|
| 110 |
-
],
|
| 111 |
-
},
|
| 112 |
-
});
|
| 113 |
-
|
| 114 |
-
expect(coachMessageResponse.ok()).toBeTruthy();
|
| 115 |
-
|
| 116 |
-
// Read streaming response
|
| 117 |
-
const coachResponseText = await coachMessageResponse.text();
|
| 118 |
-
console.log(`[TEST] Coach raw response (first 1000 chars): ${coachResponseText.substring(0, 1000)}`);
|
| 119 |
-
|
| 120 |
-
// Parse the streamed response to get the actual message content
|
| 121 |
-
// The response uses data: prefix for each line
|
| 122 |
-
const lines = coachResponseText.split('\n');
|
| 123 |
-
let messageContent = '';
|
| 124 |
-
const eventTypes = new Set<string>();
|
| 125 |
-
|
| 126 |
-
for (const line of lines) {
|
| 127 |
-
if (line.startsWith('data: ')) {
|
| 128 |
-
try {
|
| 129 |
-
const jsonStr = line.substring(6); // Remove 'data: ' prefix
|
| 130 |
-
const parsed = JSON.parse(jsonStr);
|
| 131 |
-
|
| 132 |
-
eventTypes.add(parsed.type || 'unknown');
|
| 133 |
-
|
| 134 |
-
// Look for text-delta events which contain the actual content
|
| 135 |
-
if (parsed.type === 'text-delta' && parsed.delta) {
|
| 136 |
-
messageContent += parsed.delta;
|
| 137 |
-
}
|
| 138 |
-
} catch (e) {
|
| 139 |
-
// Skip invalid JSON lines
|
| 140 |
-
}
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
console.log(`[TEST] Event types found: ${Array.from(eventTypes).join(', ')}`);
|
| 145 |
-
console.log(`[TEST] Extracted coach message: "${messageContent}"`);
|
| 146 |
-
|
| 147 |
-
// Step 4: Verify coach response addresses TEACHER (not student)
|
| 148 |
-
|
| 149 |
-
// Should contain "你" or "您" (addressing teacher)
|
| 150 |
-
expect(messageContent).toMatch(/你|您/);
|
| 151 |
-
console.log(`[TEST] ✓ Coach addresses teacher directly`);
|
| 152 |
-
|
| 153 |
-
// Should contain guidance keywords (建議、可以)
|
| 154 |
-
expect(messageContent).toMatch(/建議|可以|試試/);
|
| 155 |
-
console.log(`[TEST] ✓ Coach provides guidance/suggestions`);
|
| 156 |
-
|
| 157 |
-
// Should NOT directly tell student what to do (like "面對緊張時,可以試試深呼吸")
|
| 158 |
-
// Instead should guide teacher with reflective questions or suggestions (Satir style)
|
| 159 |
-
// Examples: "建議您可以...", "你可以思考...", "你的感受是什麼", "你希望..."
|
| 160 |
-
expect(messageContent).toMatch(/建議您|您可以|你可以|感受|希望|想了解/);
|
| 161 |
-
console.log(`[TEST] ✓ Coach provides meta-guidance (reflective questions or advice to teacher)`);
|
| 162 |
-
|
| 163 |
-
// Verify the tone is advisory to teacher, not directive to student
|
| 164 |
-
// The response should be about "how to respond" not "what student should do"
|
| 165 |
-
// Accepts both directive advice ("建議您", "例如可以這樣說") and reflective Satir style ("你的感受", "你希望")
|
| 166 |
-
const isMetaAdvice =
|
| 167 |
-
messageContent.includes('建議您') ||
|
| 168 |
-
messageContent.includes('您可以') ||
|
| 169 |
-
messageContent.includes('你可以') ||
|
| 170 |
-
messageContent.includes('例如可以這樣說') ||
|
| 171 |
-
messageContent.includes('例如:『') ||
|
| 172 |
-
messageContent.includes('你的感受') ||
|
| 173 |
-
messageContent.includes('你希望') ||
|
| 174 |
-
messageContent.includes('你內心') ||
|
| 175 |
-
messageContent.includes('是否有想過');
|
| 176 |
-
|
| 177 |
-
expect(isMetaAdvice).toBeTruthy();
|
| 178 |
-
console.log(`[TEST] ✓ Coach provides meta-guidance (directive advice or reflective questions)`);
|
| 179 |
-
});
|
| 180 |
-
|
| 181 |
-
test.afterAll(async ({ request }) => {
|
| 182 |
-
// Cleanup: delete the conversation
|
| 183 |
-
if (conversationId) {
|
| 184 |
-
await request.delete(`${API_URL}/api/conversations/${conversationId}`, {
|
| 185 |
-
headers: {
|
| 186 |
-
'Authorization': authHeader,
|
| 187 |
-
},
|
| 188 |
-
});
|
| 189 |
-
console.log(`[TEST] Cleaned up conversation: ${conversationId}`);
|
| 190 |
-
}
|
| 191 |
-
});
|
| 192 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/complete-ui-flow.spec.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('Complete UI Flow - No Hydration Errors', () => {
|
| 6 |
-
test('should complete full user journey without hydration errors', async ({ page, context }) => {
|
| 7 |
-
test.setTimeout(60_000);
|
| 8 |
-
|
| 9 |
-
// Monitor console for hydration errors
|
| 10 |
-
const consoleErrors: string[] = [];
|
| 11 |
-
page.on('console', msg => {
|
| 12 |
-
if (msg.type() === 'error' && msg.text().toLowerCase().includes('hydration')) {
|
| 13 |
-
consoleErrors.push(msg.text());
|
| 14 |
-
}
|
| 15 |
-
});
|
| 16 |
-
|
| 17 |
-
// 1. Register user
|
| 18 |
-
const username = `ui_flow_${Date.now()}`;
|
| 19 |
-
await page.goto(`${baseURL}/login`);
|
| 20 |
-
await page.click('text=沒有帳號');
|
| 21 |
-
await page.fill('input[name="username"]', username);
|
| 22 |
-
await page.click('button:has-text("建立帳號")');
|
| 23 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 24 |
-
|
| 25 |
-
// 2. Create conversation
|
| 26 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 27 |
-
await page.click('text=睿睿');
|
| 28 |
-
await page.waitForURL(/\/conversation\//, { timeout: 15_000 });
|
| 29 |
-
|
| 30 |
-
// 3. Send message and wait for LLM response
|
| 31 |
-
await page.fill('#chat-input', '你好,我需要幫助');
|
| 32 |
-
await page.keyboard.press('Enter');
|
| 33 |
-
|
| 34 |
-
// Wait for student response (blue border bubble)
|
| 35 |
-
await expect(page.locator('.bg-white.border.border-blue-100').first()).toBeVisible({ timeout: 60_000 });
|
| 36 |
-
|
| 37 |
-
// 4. Check for nested buttons before opening sidebar
|
| 38 |
-
let nestedButtons = await page.locator('button button').count();
|
| 39 |
-
expect(nestedButtons).toBe(0);
|
| 40 |
-
|
| 41 |
-
// 5. Go to dashboard and open sidebar
|
| 42 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 43 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 44 |
-
|
| 45 |
-
// Wait for sidebar to be visible
|
| 46 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible({ timeout: 5000 });
|
| 47 |
-
|
| 48 |
-
// 6. Verify no nested buttons in sidebar
|
| 49 |
-
nestedButtons = await page.locator('button button').count();
|
| 50 |
-
expect(nestedButtons).toBe(0);
|
| 51 |
-
|
| 52 |
-
// 7. Test inline editing
|
| 53 |
-
const editButton = page.locator('button[title="編輯標題"]').first();
|
| 54 |
-
await expect(editButton).toBeVisible({ timeout: 5000 });
|
| 55 |
-
await editButton.click();
|
| 56 |
-
|
| 57 |
-
// 8. Verify edit input appears
|
| 58 |
-
const titleInput = page.locator('input[type="text"]').first();
|
| 59 |
-
await expect(titleInput).toBeVisible();
|
| 60 |
-
|
| 61 |
-
// 9. Edit title
|
| 62 |
-
await titleInput.fill('我的測試對話');
|
| 63 |
-
await titleInput.press('Enter');
|
| 64 |
-
|
| 65 |
-
// 10. Verify title updated
|
| 66 |
-
await expect(page.locator('text=我的測試對話').first()).toBeVisible();
|
| 67 |
-
|
| 68 |
-
// 11. Check for any hydration errors
|
| 69 |
-
expect(consoleErrors).toHaveLength(0);
|
| 70 |
-
|
| 71 |
-
console.log('✅ Complete UI flow successful without hydration errors');
|
| 72 |
-
console.log('✅ Inline editing working correctly');
|
| 73 |
-
console.log('✅ No nested buttons detected');
|
| 74 |
-
});
|
| 75 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/{concurrency-race-conditions.spec.ts → concurrency-race-conditions.api.spec.ts}
RENAMED
|
File without changes
|
tests/e2e/conversation-delete.spec.ts
DELETED
|
@@ -1,351 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
const PW = 'cz-2025';
|
| 5 |
-
const authHeader = (u: string) => 'Basic ' + Buffer.from(`${u}:${PW}`).toString('base64');
|
| 6 |
-
|
| 7 |
-
// Helper function to register and login
|
| 8 |
-
async function registerAndLogin(page: any, username: string) {
|
| 9 |
-
await page.goto(`${baseURL}/login`);
|
| 10 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 11 |
-
await page.fill('input[name="username"]', username);
|
| 12 |
-
await page.click('button:has-text("建立帳號")');
|
| 13 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
// Helper to create a conversation
|
| 17 |
-
async function createConversation(page: any, studentName: string = '睿睿') {
|
| 18 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 19 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 20 |
-
await page.click(`text=${studentName}`);
|
| 21 |
-
await page.waitForURL(/\/conversation\//, { timeout: 15_000 });
|
| 22 |
-
// Wait for conversation to be fully saved to database
|
| 23 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10000 });
|
| 24 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
test.describe('Backend API - Delete Conversation', () => {
|
| 28 |
-
test('should successfully delete a conversation via API', async ({ request }) => {
|
| 29 |
-
const testUser = `delete_api_${Date.now()}`;
|
| 30 |
-
|
| 31 |
-
// 1. Register user
|
| 32 |
-
const regResp = await request.post(`${baseURL}/api/auth/register`, {
|
| 33 |
-
data: { username: testUser },
|
| 34 |
-
});
|
| 35 |
-
expect(regResp.ok()).toBeTruthy();
|
| 36 |
-
|
| 37 |
-
// 2. Create conversation
|
| 38 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 39 |
-
headers: {
|
| 40 |
-
Authorization: authHeader(testUser),
|
| 41 |
-
'Content-Type': 'application/json',
|
| 42 |
-
},
|
| 43 |
-
data: {
|
| 44 |
-
studentPromptId: 'ruirui',
|
| 45 |
-
coachPromptId: 'satir',
|
| 46 |
-
},
|
| 47 |
-
});
|
| 48 |
-
expect(convResp.ok()).toBeTruthy();
|
| 49 |
-
const convData = await convResp.json();
|
| 50 |
-
const conversationId = convData.conversation.id;
|
| 51 |
-
|
| 52 |
-
// 3. Verify conversation exists
|
| 53 |
-
const getResp = await request.get(`${baseURL}/api/conversations/${conversationId}`, {
|
| 54 |
-
headers: { Authorization: authHeader(testUser) },
|
| 55 |
-
});
|
| 56 |
-
expect(getResp.ok()).toBeTruthy();
|
| 57 |
-
|
| 58 |
-
// 4. Delete conversation
|
| 59 |
-
const deleteResp = await request.delete(`${baseURL}/api/conversations/${conversationId}`, {
|
| 60 |
-
headers: { Authorization: authHeader(testUser) },
|
| 61 |
-
});
|
| 62 |
-
expect(deleteResp.ok()).toBeTruthy();
|
| 63 |
-
const deleteData = await deleteResp.json();
|
| 64 |
-
expect(deleteData.success).toBe(true);
|
| 65 |
-
|
| 66 |
-
// 5. Verify conversation no longer exists
|
| 67 |
-
const getAfterDelete = await request.get(`${baseURL}/api/conversations/${conversationId}`, {
|
| 68 |
-
headers: { Authorization: authHeader(testUser) },
|
| 69 |
-
});
|
| 70 |
-
expect(getAfterDelete.status()).toBe(404);
|
| 71 |
-
});
|
| 72 |
-
|
| 73 |
-
test('should return 404 when deleting non-existent conversation', async ({ request }) => {
|
| 74 |
-
const testUser = `delete_404_${Date.now()}`;
|
| 75 |
-
|
| 76 |
-
// Register user
|
| 77 |
-
const regResp = await request.post(`${baseURL}/api/auth/register`, {
|
| 78 |
-
data: { username: testUser },
|
| 79 |
-
});
|
| 80 |
-
expect(regResp.ok()).toBeTruthy();
|
| 81 |
-
|
| 82 |
-
// Try to delete non-existent conversation
|
| 83 |
-
const deleteResp = await request.delete(`${baseURL}/api/conversations/nonexistent-id-123`, {
|
| 84 |
-
headers: { Authorization: authHeader(testUser) },
|
| 85 |
-
});
|
| 86 |
-
expect(deleteResp.status()).toBe(404);
|
| 87 |
-
});
|
| 88 |
-
|
| 89 |
-
test('should return 401 when deleting without authentication', async ({ request }) => {
|
| 90 |
-
// Try to delete without auth header
|
| 91 |
-
const deleteResp = await request.delete(`${baseURL}/api/conversations/some-id`);
|
| 92 |
-
expect(deleteResp.status()).toBe(401);
|
| 93 |
-
});
|
| 94 |
-
|
| 95 |
-
test('should return 404 when deleting another user\'s conversation', async ({ request }) => {
|
| 96 |
-
const user1 = `delete_owner_${Date.now()}`;
|
| 97 |
-
const user2 = `delete_thief_${Date.now()}`;
|
| 98 |
-
|
| 99 |
-
// Register both users
|
| 100 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: user1 } });
|
| 101 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: user2 } });
|
| 102 |
-
|
| 103 |
-
// User 1 creates conversation
|
| 104 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 105 |
-
headers: {
|
| 106 |
-
Authorization: authHeader(user1),
|
| 107 |
-
'Content-Type': 'application/json',
|
| 108 |
-
},
|
| 109 |
-
data: {
|
| 110 |
-
studentPromptId: 'ruirui',
|
| 111 |
-
coachPromptId: 'satir',
|
| 112 |
-
},
|
| 113 |
-
});
|
| 114 |
-
const convData = await convResp.json();
|
| 115 |
-
const conversationId = convData.conversation.id;
|
| 116 |
-
|
| 117 |
-
// User 2 tries to delete user 1's conversation
|
| 118 |
-
const deleteResp = await request.delete(`${baseURL}/api/conversations/${conversationId}`, {
|
| 119 |
-
headers: { Authorization: authHeader(user2) },
|
| 120 |
-
});
|
| 121 |
-
expect(deleteResp.status()).toBe(404); // Should return 404 (not found for this user)
|
| 122 |
-
|
| 123 |
-
// Verify conversation still exists for user 1
|
| 124 |
-
const getResp = await request.get(`${baseURL}/api/conversations/${conversationId}`, {
|
| 125 |
-
headers: { Authorization: authHeader(user1) },
|
| 126 |
-
});
|
| 127 |
-
expect(getResp.ok()).toBeTruthy();
|
| 128 |
-
});
|
| 129 |
-
});
|
| 130 |
-
|
| 131 |
-
test.describe('UI - Delete Conversation in Sidebar', () => {
|
| 132 |
-
test('should show delete button on hover and confirmation modal', async ({ page }) => {
|
| 133 |
-
test.setTimeout(60_000);
|
| 134 |
-
const testUser = `delete_ui_hover_${Date.now()}`;
|
| 135 |
-
|
| 136 |
-
// 1. Register and create conversation
|
| 137 |
-
await registerAndLogin(page, testUser);
|
| 138 |
-
await createConversation(page);
|
| 139 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 140 |
-
|
| 141 |
-
// 2. Open sidebar
|
| 142 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 143 |
-
|
| 144 |
-
// 3. Hover over conversation to reveal buttons
|
| 145 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 146 |
-
await conversationItem.hover();
|
| 147 |
-
|
| 148 |
-
// 4. Verify delete button is visible on hover
|
| 149 |
-
const deleteButton = conversationItem.locator('button[title="刪除對話"]');
|
| 150 |
-
await expect(deleteButton).toBeVisible({ timeout: 2000 });
|
| 151 |
-
|
| 152 |
-
// 5. Click delete button
|
| 153 |
-
await deleteButton.click();
|
| 154 |
-
|
| 155 |
-
// 6. Verify confirmation modal appears
|
| 156 |
-
await expect(page.locator('h3:has-text("確認刪除對話")')).toBeVisible();
|
| 157 |
-
await expect(page.locator('text=確定要刪除此對話嗎?此操作無法復原。')).toBeVisible();
|
| 158 |
-
await expect(page.locator('button:has-text("取消")')).toBeVisible();
|
| 159 |
-
await expect(page.locator('button:has-text("確認刪除")')).toBeVisible();
|
| 160 |
-
});
|
| 161 |
-
|
| 162 |
-
test('should cancel deletion when clicking 取消', async ({ page }) => {
|
| 163 |
-
test.setTimeout(60_000);
|
| 164 |
-
const testUser = `delete_ui_cancel_${Date.now()}`;
|
| 165 |
-
|
| 166 |
-
// Setup
|
| 167 |
-
await registerAndLogin(page, testUser);
|
| 168 |
-
await createConversation(page);
|
| 169 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 170 |
-
|
| 171 |
-
// Open sidebar and click delete
|
| 172 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 173 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 174 |
-
await expect(conversationItem).toBeVisible({ timeout: 2000 });
|
| 175 |
-
const conversationTitle = await conversationItem.locator('p.text-sm.font-medium').textContent();
|
| 176 |
-
await conversationItem.hover();
|
| 177 |
-
await conversationItem.locator('button[title="刪除對話"]').click();
|
| 178 |
-
|
| 179 |
-
// Click 取消
|
| 180 |
-
await page.click('button:has-text("取消")');
|
| 181 |
-
|
| 182 |
-
// Verify modal is closed
|
| 183 |
-
await expect(page.locator('h3:has-text("確認刪除對話")')).not.toBeVisible({ timeout: 2000 });
|
| 184 |
-
|
| 185 |
-
// Verify conversation still exists in sidebar
|
| 186 |
-
await expect(page.locator(`p.text-sm.font-medium:has-text("${conversationTitle}")`)).toBeVisible();
|
| 187 |
-
});
|
| 188 |
-
|
| 189 |
-
test('should delete conversation when clicking 確認刪除', async ({ page }) => {
|
| 190 |
-
test.setTimeout(60_000);
|
| 191 |
-
const testUser = `delete_ui_confirm_${Date.now()}`;
|
| 192 |
-
|
| 193 |
-
// Setup
|
| 194 |
-
await registerAndLogin(page, testUser);
|
| 195 |
-
await createConversation(page);
|
| 196 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 197 |
-
|
| 198 |
-
// Open sidebar
|
| 199 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 200 |
-
|
| 201 |
-
// Get conversation title before deletion
|
| 202 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 203 |
-
await expect(conversationItem).toBeVisible({ timeout: 2000 });
|
| 204 |
-
const conversationTitle = await conversationItem.locator('p.text-sm.font-medium').textContent();
|
| 205 |
-
|
| 206 |
-
// Click delete and confirm
|
| 207 |
-
await conversationItem.hover();
|
| 208 |
-
await conversationItem.locator('button[title="刪除對話"]').click();
|
| 209 |
-
await page.click('button:has-text("確認刪除")');
|
| 210 |
-
|
| 211 |
-
// Verify modal is closed
|
| 212 |
-
await expect(page.locator('h3:has-text("確認刪除對話")')).not.toBeVisible();
|
| 213 |
-
|
| 214 |
-
// Verify conversation is removed from sidebar
|
| 215 |
-
await expect(page.locator(`p.text-sm.font-medium:has-text("${conversationTitle}")`)).not.toBeVisible();
|
| 216 |
-
|
| 217 |
-
// Verify empty state is shown
|
| 218 |
-
await expect(page.locator('text=尚無對話記錄')).toBeVisible({ timeout: 5000 });
|
| 219 |
-
});
|
| 220 |
-
|
| 221 |
-
test('should delete multiple conversations independently', async ({ page }) => {
|
| 222 |
-
test.setTimeout(120_000); // 2 minutes for multiple operations
|
| 223 |
-
const testUser = `delete_ui_multiple_${Date.now()}`;
|
| 224 |
-
|
| 225 |
-
// Create 3 conversations
|
| 226 |
-
await registerAndLogin(page, testUser);
|
| 227 |
-
await createConversation(page, '睿睿');
|
| 228 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 229 |
-
await createConversation(page, '小許');
|
| 230 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 231 |
-
await createConversation(page, '小恩');
|
| 232 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 233 |
-
|
| 234 |
-
// Open sidebar
|
| 235 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 236 |
-
|
| 237 |
-
// Verify 3 conversations exist
|
| 238 |
-
const allConversations = page.locator('.space-y-1 > div');
|
| 239 |
-
await expect(allConversations).toHaveCount(3, { timeout: 5000 });
|
| 240 |
-
|
| 241 |
-
// Delete the second conversation
|
| 242 |
-
const secondConv = allConversations.nth(1);
|
| 243 |
-
const secondTitle = await secondConv.locator('p.text-sm.font-medium').textContent();
|
| 244 |
-
await secondConv.hover();
|
| 245 |
-
await secondConv.locator('button[title="刪除對話"]').click();
|
| 246 |
-
await page.click('button:has-text("確認刪除")');
|
| 247 |
-
|
| 248 |
-
// Verify only 2 conversations remain
|
| 249 |
-
await expect(allConversations).toHaveCount(2, { timeout: 5000 });
|
| 250 |
-
|
| 251 |
-
// Verify the deleted conversation is gone
|
| 252 |
-
await expect(page.locator(`p.text-sm.font-medium:has-text("${secondTitle}")`)).not.toBeVisible();
|
| 253 |
-
});
|
| 254 |
-
|
| 255 |
-
test('should show delete button for coach conversations', async ({ page }) => {
|
| 256 |
-
test.setTimeout(60_000);
|
| 257 |
-
const testUser = `delete_ui_coach_${Date.now()}`;
|
| 258 |
-
|
| 259 |
-
// Create a coach conversation via dashboard
|
| 260 |
-
await registerAndLogin(page, testUser);
|
| 261 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 262 |
-
|
| 263 |
-
// Click coach card and confirm
|
| 264 |
-
await page.click('h3:has-text("教練回顧")');
|
| 265 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 266 |
-
await page.click('button:has-text("開始諮詢")');
|
| 267 |
-
|
| 268 |
-
await page.waitForURL(/\/conversation\//, { timeout: 15_000 });
|
| 269 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 270 |
-
|
| 271 |
-
// Open sidebar
|
| 272 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 273 |
-
|
| 274 |
-
// Verify coach conversation has delete button
|
| 275 |
-
const coachConv = page.locator('.space-y-1 > div').filter({ hasText: '👨🏫' }).first();
|
| 276 |
-
await expect(coachConv).toBeVisible({ timeout: 2000 });
|
| 277 |
-
await coachConv.hover();
|
| 278 |
-
const deleteButton = coachConv.locator('button[title="刪除對話"]');
|
| 279 |
-
await expect(deleteButton).toBeVisible();
|
| 280 |
-
|
| 281 |
-
// Delete and verify it works
|
| 282 |
-
await deleteButton.click();
|
| 283 |
-
await expect(page.locator('h3:has-text("確認刪除對話")')).toBeVisible();
|
| 284 |
-
await page.click('button:has-text("確認刪除")');
|
| 285 |
-
|
| 286 |
-
// Verify coach conversation is deleted
|
| 287 |
-
await expect(page.locator('text=尚無對話記錄')).toBeVisible({ timeout: 5000 });
|
| 288 |
-
});
|
| 289 |
-
});
|
| 290 |
-
|
| 291 |
-
test.describe('Edge Cases', () => {
|
| 292 |
-
test('should show only one modal when delete button is clicked', async ({ page }) => {
|
| 293 |
-
test.setTimeout(60_000);
|
| 294 |
-
const testUser = `delete_edge_modal_${Date.now()}`;
|
| 295 |
-
|
| 296 |
-
await registerAndLogin(page, testUser);
|
| 297 |
-
await createConversation(page);
|
| 298 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 299 |
-
|
| 300 |
-
// Open sidebar and hover
|
| 301 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 302 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 303 |
-
await expect(conversationItem).toBeVisible({ timeout: 2000 });
|
| 304 |
-
await conversationItem.hover();
|
| 305 |
-
|
| 306 |
-
// Click delete button
|
| 307 |
-
const deleteButton = conversationItem.locator('button[title="刪除對話"]');
|
| 308 |
-
await deleteButton.click();
|
| 309 |
-
|
| 310 |
-
// Modal should appear exactly once
|
| 311 |
-
const modals = page.locator('h3:has-text("確認刪除對話")');
|
| 312 |
-
await expect(modals).toHaveCount(1);
|
| 313 |
-
|
| 314 |
-
// Cancel the delete
|
| 315 |
-
await page.click('button:has-text("取消")');
|
| 316 |
-
await expect(page.locator('h3:has-text("確認刪除對話")')).not.toBeVisible({ timeout: 2000 });
|
| 317 |
-
});
|
| 318 |
-
|
| 319 |
-
test('should successfully delete and update UI state', async ({ page }) => {
|
| 320 |
-
test.setTimeout(60_000);
|
| 321 |
-
const testUser = `delete_edge_ui_update_${Date.now()}`;
|
| 322 |
-
|
| 323 |
-
await registerAndLogin(page, testUser);
|
| 324 |
-
await createConversation(page);
|
| 325 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 326 |
-
|
| 327 |
-
// Open sidebar
|
| 328 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 329 |
-
|
| 330 |
-
// Click delete and confirm
|
| 331 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 332 |
-
await expect(conversationItem).toBeVisible({ timeout: 2000 });
|
| 333 |
-
await conversationItem.hover();
|
| 334 |
-
await conversationItem.locator('button[title="刪除對話"]').click();
|
| 335 |
-
|
| 336 |
-
// Verify modal appears and confirm deletion
|
| 337 |
-
await expect(page.locator('h3:has-text("確認刪除對話")')).toBeVisible();
|
| 338 |
-
await page.click('button:has-text("確認刪除")');
|
| 339 |
-
|
| 340 |
-
// Verify empty state in sidebar (deletion succeeded)
|
| 341 |
-
await expect(page.locator('text=尚無對話記錄')).toBeVisible({ timeout: 5000 });
|
| 342 |
-
|
| 343 |
-
// Close sidebar
|
| 344 |
-
await page.click('.fixed.inset-0.bg-black\\/50');
|
| 345 |
-
await expect(page.locator('.fixed.inset-0.bg-black\\/50')).not.toBeVisible({ timeout: 2000 });
|
| 346 |
-
|
| 347 |
-
// Open sidebar again to verify persistence
|
| 348 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 349 |
-
await expect(page.locator('text=尚無對話記錄')).toBeVisible({ timeout: 2000 });
|
| 350 |
-
});
|
| 351 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/conversation-titles.spec.ts
DELETED
|
@@ -1,329 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
// Helper to create a conversation
|
| 15 |
-
async function createConversation(page: any, studentName: string = '睿睿') {
|
| 16 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 17 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 18 |
-
await page.click(`text=${studentName}`);
|
| 19 |
-
await page.waitForURL(/\/conversation\//, { timeout: 15_000 });
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
// Helper to send a message and wait for response
|
| 23 |
-
async function sendMessage(page: any, message: string) {
|
| 24 |
-
await page.fill('input#chat-input', message);
|
| 25 |
-
await page.keyboard.press('Enter');
|
| 26 |
-
await expect(page.locator('.whitespace-pre-wrap').first()).toBeVisible({ timeout: 60000 });
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
test.describe('Conversation Title Auto-Generation', () => {
|
| 30 |
-
test('should auto-generate title after first few messages', async ({ page }) => {
|
| 31 |
-
test.setTimeout(60_000); // 2 minutes for LLM responses
|
| 32 |
-
const testUser = `title_auto_${Date.now()}`;
|
| 33 |
-
|
| 34 |
-
// 1. Register and login
|
| 35 |
-
await registerAndLogin(page, testUser);
|
| 36 |
-
|
| 37 |
-
// 2. Create new conversation
|
| 38 |
-
await createConversation(page);
|
| 39 |
-
|
| 40 |
-
// 3. Send first message
|
| 41 |
-
await sendMessage(page, '你好,我今天心情不好');
|
| 42 |
-
|
| 43 |
-
// 4. Open sidebar to check title
|
| 44 |
-
await page.click('button[aria-label="menu"], svg:has(path[d*="M4 6h16"])');
|
| 45 |
-
|
| 46 |
-
// 6. Verify a title exists (not default "新對話" or "與 XX 的對話")
|
| 47 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 48 |
-
const titleText = await conversationItem.locator('p.text-sm.font-medium').textContent();
|
| 49 |
-
|
| 50 |
-
// Title should be auto-generated (not empty and not default)
|
| 51 |
-
expect(titleText).toBeTruthy();
|
| 52 |
-
expect(titleText).not.toBe('新對話');
|
| 53 |
-
|
| 54 |
-
console.log('Auto-generated title:', titleText);
|
| 55 |
-
});
|
| 56 |
-
});
|
| 57 |
-
|
| 58 |
-
test.describe('Inline Title Editing - Sidebar', () => {
|
| 59 |
-
test('should allow editing conversation title in sidebar', async ({ page }) => {
|
| 60 |
-
test.setTimeout(60_000);
|
| 61 |
-
const testUser = `title_edit_sidebar_${Date.now()}`;
|
| 62 |
-
|
| 63 |
-
// 1. Register and create conversation
|
| 64 |
-
await registerAndLogin(page, testUser);
|
| 65 |
-
await createConversation(page);
|
| 66 |
-
await sendMessage(page, '測試訊息');
|
| 67 |
-
|
| 68 |
-
// 2. Open sidebar
|
| 69 |
-
await page.click('button[aria-label="menu"], svg:has(path[d*="M4 6h16"])');
|
| 70 |
-
|
| 71 |
-
// 3. Hover over conversation to reveal edit button
|
| 72 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 73 |
-
await conversationItem.hover();
|
| 74 |
-
|
| 75 |
-
// 4. Click edit button (pencil icon)
|
| 76 |
-
await conversationItem.locator('button[title="編輯標題"]').click();
|
| 77 |
-
|
| 78 |
-
// 5. Edit the title
|
| 79 |
-
const titleInput = conversationItem.locator('input[type="text"]');
|
| 80 |
-
await expect(titleInput).toBeVisible();
|
| 81 |
-
await titleInput.fill('我的自訂標題');
|
| 82 |
-
|
| 83 |
-
// 6. Press Enter to save
|
| 84 |
-
await titleInput.press('Enter');
|
| 85 |
-
|
| 86 |
-
// 7. Verify title is updated
|
| 87 |
-
await expect(conversationItem.locator('p.text-sm.font-medium:has-text("我的自訂標題")')).toBeVisible();
|
| 88 |
-
});
|
| 89 |
-
|
| 90 |
-
test('should cancel edit on Escape key in sidebar', async ({ page }) => {
|
| 91 |
-
test.setTimeout(60_000);
|
| 92 |
-
const testUser = `title_cancel_sidebar_${Date.now()}`;
|
| 93 |
-
|
| 94 |
-
await registerAndLogin(page, testUser);
|
| 95 |
-
await createConversation(page);
|
| 96 |
-
await sendMessage(page, '測試');
|
| 97 |
-
|
| 98 |
-
// Open sidebar and get original title
|
| 99 |
-
await page.click('button[aria-label="menu"], svg:has(path[d*="M4 6h16"])');
|
| 100 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 101 |
-
const originalTitle = await conversationItem.locator('p.text-sm.font-medium').textContent();
|
| 102 |
-
|
| 103 |
-
// Start editing
|
| 104 |
-
await conversationItem.hover();
|
| 105 |
-
await conversationItem.locator('button[title="編輯標題"]').click();
|
| 106 |
-
|
| 107 |
-
// Change text but press Escape
|
| 108 |
-
const titleInput = conversationItem.locator('input[type="text"]');
|
| 109 |
-
await titleInput.fill('不要儲存這個');
|
| 110 |
-
await titleInput.press('Escape');
|
| 111 |
-
await expect(titleInput).not.toBeVisible({ timeout: 2000 });
|
| 112 |
-
|
| 113 |
-
// Verify original title is preserved
|
| 114 |
-
const currentTitle = await conversationItem.locator('p.text-sm.font-medium').textContent();
|
| 115 |
-
expect(currentTitle).toBe(originalTitle);
|
| 116 |
-
});
|
| 117 |
-
});
|
| 118 |
-
|
| 119 |
-
test.describe('Inline Title Editing - Dashboard', () => {
|
| 120 |
-
test('should allow editing conversation title in dashboard modal', async ({ page }) => {
|
| 121 |
-
test.setTimeout(60_000);
|
| 122 |
-
const testUser = `title_edit_dashboard_${Date.now()}`;
|
| 123 |
-
|
| 124 |
-
// 1. Create conversation
|
| 125 |
-
await registerAndLogin(page, testUser);
|
| 126 |
-
await createConversation(page);
|
| 127 |
-
await sendMessage(page, '測試訊息');
|
| 128 |
-
|
| 129 |
-
// 2. Go back to dashboard
|
| 130 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 131 |
-
|
| 132 |
-
// 3. Open sidebar to see conversation list
|
| 133 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 134 |
-
await expect(page.locator('text=對話記錄')).toBeVisible({ timeout: 2000 });
|
| 135 |
-
|
| 136 |
-
// 4. Hover over conversation to reveal edit button
|
| 137 |
-
const conversationCard = page.locator('.space-y-3 > div').first();
|
| 138 |
-
await conversationCard.hover();
|
| 139 |
-
|
| 140 |
-
// 5. Click edit button
|
| 141 |
-
await conversationCard.locator('button[title="編輯標題"]').click();
|
| 142 |
-
|
| 143 |
-
// 6. Edit the title
|
| 144 |
-
const titleInput = conversationCard.locator('input[type="text"]');
|
| 145 |
-
await expect(titleInput).toBeVisible();
|
| 146 |
-
await titleInput.fill('Dashboard編輯標題');
|
| 147 |
-
|
| 148 |
-
// 7. Click outside to save (blur)
|
| 149 |
-
await page.locator('h3:has-text("對話記錄")').click();
|
| 150 |
-
await expect(conversationCard.locator('input[type="text"]')).not.toBeVisible({ timeout: 2000 });
|
| 151 |
-
|
| 152 |
-
// 8. Verify title is updated
|
| 153 |
-
await expect(conversationCard.locator('h4:has-text("Dashboard編輯標題")')).toBeVisible();
|
| 154 |
-
});
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
test.describe('Title Source Persistence', () => {
|
| 158 |
-
test('should not auto-update user-edited titles', async ({ page }) => {
|
| 159 |
-
test.setTimeout(60_000); // 3 minutes for multiple LLM responses
|
| 160 |
-
const testUser = `title_persist_${Date.now()}`;
|
| 161 |
-
|
| 162 |
-
// 1. Create conversation and let auto-title generate
|
| 163 |
-
await registerAndLogin(page, testUser);
|
| 164 |
-
await createConversation(page);
|
| 165 |
-
await sendMessage(page, '我需要幫助');
|
| 166 |
-
|
| 167 |
-
// 2. Manually edit the title via sidebar
|
| 168 |
-
await page.click('button[aria-label="menu"], svg:has(path[d*="M4 6h16"])');
|
| 169 |
-
const conversationItem = page.locator('.space-y-1 > div').first();
|
| 170 |
-
await conversationItem.hover();
|
| 171 |
-
await conversationItem.locator('button[title="編輯標題"]').click();
|
| 172 |
-
|
| 173 |
-
const customTitle = '我的固定標題-不要改';
|
| 174 |
-
const titleInput = conversationItem.locator('input[type="text"]');
|
| 175 |
-
await titleInput.fill(customTitle);
|
| 176 |
-
await titleInput.press('Enter');
|
| 177 |
-
await expect(titleInput).not.toBeVisible({ timeout: 2000 });
|
| 178 |
-
|
| 179 |
-
// 3. Close sidebar
|
| 180 |
-
await page.click('.fixed.inset-0.bg-black\\/50');
|
| 181 |
-
|
| 182 |
-
// 4. Send more messages to trigger auto-title logic
|
| 183 |
-
await sendMessage(page, '我想談談完全不同的話題');
|
| 184 |
-
await sendMessage(page, '關於數學作業的問題');
|
| 185 |
-
|
| 186 |
-
// 5. Verify title remains unchanged
|
| 187 |
-
await page.click('button[aria-label="menu"], svg:has(path[d*="M4 6h16"])');
|
| 188 |
-
const updatedConvItem = page.locator('.space-y-1 > div').first();
|
| 189 |
-
const finalTitle = await updatedConvItem.locator('p.text-sm.font-medium').textContent();
|
| 190 |
-
|
| 191 |
-
expect(finalTitle).toBe(customTitle);
|
| 192 |
-
console.log('User title preserved after additional messages:', finalTitle);
|
| 193 |
-
});
|
| 194 |
-
});
|
| 195 |
-
|
| 196 |
-
test.describe('UI No Hydration Errors', () => {
|
| 197 |
-
test('should not have nested buttons in Sidebar - verify DOM structure', async ({ page }) => {
|
| 198 |
-
test.setTimeout(60_000);
|
| 199 |
-
const testUser = `hydration_test_${Date.now()}`;
|
| 200 |
-
|
| 201 |
-
// Register and create conversation
|
| 202 |
-
await registerAndLogin(page, testUser);
|
| 203 |
-
await createConversation(page);
|
| 204 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 205 |
-
|
| 206 |
-
// Open sidebar
|
| 207 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 208 |
-
await expect(page.locator('button[title="編輯標題"]').first()).toBeVisible({ timeout: 5000 });
|
| 209 |
-
|
| 210 |
-
// Check if any button contains another button (which would cause hydration error)
|
| 211 |
-
const nestedButtons = await page.locator('button button').count();
|
| 212 |
-
expect(nestedButtons).toBe(0);
|
| 213 |
-
|
| 214 |
-
// Verify conversation list exists and edit button is accessible
|
| 215 |
-
const editButton = await page.locator('button[title="編輯標題"]').first();
|
| 216 |
-
await expect(editButton).toBeVisible({ timeout: 5000 });
|
| 217 |
-
|
| 218 |
-
console.log('✅ No nested buttons found - hydration error fixed');
|
| 219 |
-
});
|
| 220 |
-
|
| 221 |
-
test('should allow independent click actions in Sidebar', async ({ page }) => {
|
| 222 |
-
test.setTimeout(60_000);
|
| 223 |
-
const testUser = `click_test_${Date.now()}`;
|
| 224 |
-
|
| 225 |
-
await registerAndLogin(page, testUser);
|
| 226 |
-
await createConversation(page);
|
| 227 |
-
await sendMessage(page, '測試訊息');
|
| 228 |
-
|
| 229 |
-
// Go to dashboard and open sidebar
|
| 230 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 231 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 232 |
-
await expect(page.locator('text=對話記錄')).toBeVisible({ timeout: 2000 });
|
| 233 |
-
|
| 234 |
-
// Click edit button - should NOT navigate
|
| 235 |
-
const editButton = await page.locator('button[title="編輯標題"]').first();
|
| 236 |
-
await editButton.click();
|
| 237 |
-
|
| 238 |
-
// Should show input field
|
| 239 |
-
const titleInput = page.locator('input[type="text"]').first();
|
| 240 |
-
await expect(titleInput).toBeVisible();
|
| 241 |
-
|
| 242 |
-
// Still on dashboard
|
| 243 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`);
|
| 244 |
-
|
| 245 |
-
console.log('✅ Edit button works without triggering navigation');
|
| 246 |
-
});
|
| 247 |
-
});
|
| 248 |
-
|
| 249 |
-
test.describe('API Verification', () => {
|
| 250 |
-
test('should create conversation with titleSource=auto by default', async ({ page }) => {
|
| 251 |
-
test.setTimeout(60_000);
|
| 252 |
-
|
| 253 |
-
// Create user via API
|
| 254 |
-
const username = `api_test_${Date.now()}`;
|
| 255 |
-
const registerResponse = await page.request.post(`${baseURL}/api/auth/register`, {
|
| 256 |
-
data: { username, password: 'cz-2025' }
|
| 257 |
-
});
|
| 258 |
-
expect(registerResponse.ok()).toBeTruthy();
|
| 259 |
-
|
| 260 |
-
// Create conversation via API
|
| 261 |
-
const basicAuth = Buffer.from(`${username}:cz-2025`).toString('base64');
|
| 262 |
-
const convResponse = await page.request.post(`${baseURL}/api/conversations/create`, {
|
| 263 |
-
headers: {
|
| 264 |
-
'Authorization': `Basic ${basicAuth}`,
|
| 265 |
-
'Content-Type': 'application/json'
|
| 266 |
-
},
|
| 267 |
-
data: {
|
| 268 |
-
studentPromptId: 'ruirui',
|
| 269 |
-
coachPromptId: 'satir'
|
| 270 |
-
}
|
| 271 |
-
});
|
| 272 |
-
|
| 273 |
-
expect(convResponse.ok()).toBeTruthy();
|
| 274 |
-
const convData = await convResponse.json();
|
| 275 |
-
|
| 276 |
-
// Verify titleSource is 'auto'
|
| 277 |
-
expect(convData.conversation.titleSource).toBe('auto');
|
| 278 |
-
console.log('API: Conversation created with titleSource=auto');
|
| 279 |
-
});
|
| 280 |
-
|
| 281 |
-
test('should update titleSource to user when manually editing', async ({ page }) => {
|
| 282 |
-
test.setTimeout(60_000);
|
| 283 |
-
|
| 284 |
-
// Create user and conversation via API
|
| 285 |
-
const username = `api_edit_${Date.now()}`;
|
| 286 |
-
const registerResponse = await page.request.post(`${baseURL}/api/auth/register`, {
|
| 287 |
-
data: { username, password: 'cz-2025' }
|
| 288 |
-
});
|
| 289 |
-
const userData = await registerResponse.json();
|
| 290 |
-
|
| 291 |
-
const basicAuth = Buffer.from(`${username}:cz-2025`).toString('base64');
|
| 292 |
-
const convResponse = await page.request.post(`${baseURL}/api/conversations/create`, {
|
| 293 |
-
headers: {
|
| 294 |
-
'Authorization': `Basic ${basicAuth}`,
|
| 295 |
-
'Content-Type': 'application/json'
|
| 296 |
-
},
|
| 297 |
-
data: {
|
| 298 |
-
studentPromptId: 'xiaoxu',
|
| 299 |
-
coachPromptId: 'satir'
|
| 300 |
-
}
|
| 301 |
-
});
|
| 302 |
-
const convData = await convResponse.json();
|
| 303 |
-
const convId = convData.conversation.id;
|
| 304 |
-
|
| 305 |
-
// Update title via API
|
| 306 |
-
const updateResponse = await page.request.put(`${baseURL}/api/conversations/${convId}`, {
|
| 307 |
-
headers: {
|
| 308 |
-
'Authorization': `Basic ${basicAuth}`,
|
| 309 |
-
'Content-Type': 'application/json'
|
| 310 |
-
},
|
| 311 |
-
data: {
|
| 312 |
-
title: 'API Updated Title',
|
| 313 |
-
titleSource: 'user'
|
| 314 |
-
}
|
| 315 |
-
});
|
| 316 |
-
|
| 317 |
-
if (!updateResponse.ok()) {
|
| 318 |
-
const errorText = await updateResponse.text();
|
| 319 |
-
console.error('Update failed:', updateResponse.status(), errorText);
|
| 320 |
-
}
|
| 321 |
-
expect(updateResponse.ok()).toBeTruthy();
|
| 322 |
-
const updateData = await updateResponse.json();
|
| 323 |
-
|
| 324 |
-
// Verify titleSource changed to 'user'
|
| 325 |
-
expect(updateData.conversation.titleSource).toBe('user');
|
| 326 |
-
expect(updateData.conversation.title).toBe('API Updated Title');
|
| 327 |
-
console.log('API: Title source updated to user');
|
| 328 |
-
});
|
| 329 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/dashboard-many-conversations.spec.ts
DELETED
|
@@ -1,334 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
// Helper to create a conversation via API
|
| 15 |
-
async function createConversationViaAPI(
|
| 16 |
-
username: string,
|
| 17 |
-
studentPromptId: string,
|
| 18 |
-
coachPromptId: string = 'satir',
|
| 19 |
-
title?: string
|
| 20 |
-
) {
|
| 21 |
-
const authHeader = Buffer.from(`${username}:cz-2025`).toString('base64');
|
| 22 |
-
const response = await fetch(`${baseURL}/api/conversations/create`, {
|
| 23 |
-
method: 'POST',
|
| 24 |
-
headers: {
|
| 25 |
-
'Content-Type': 'application/json',
|
| 26 |
-
'Authorization': `Basic ${authHeader}`,
|
| 27 |
-
},
|
| 28 |
-
body: JSON.stringify({
|
| 29 |
-
studentPromptId,
|
| 30 |
-
coachPromptId,
|
| 31 |
-
include3ConversationSummary: false,
|
| 32 |
-
...(title && { title }),
|
| 33 |
-
}),
|
| 34 |
-
});
|
| 35 |
-
|
| 36 |
-
if (!response.ok) {
|
| 37 |
-
throw new Error(`Failed to create conversation: ${response.status}`);
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
const data = await response.json();
|
| 41 |
-
return data.conversation.id;
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
// SKIPPED: "自訂對話演練" card is hidden in UI
|
| 45 |
-
test.describe.skip('Dashboard with Many Conversations', () => {
|
| 46 |
-
test('should create 15 conversations and verify dashboard accessibility', async ({ page }) => {
|
| 47 |
-
test.setTimeout(120_000); // 2 minutes
|
| 48 |
-
const testUser = `many_conv_${Date.now()}`;
|
| 49 |
-
|
| 50 |
-
// Step 1: Register and login
|
| 51 |
-
await registerAndLogin(page, testUser);
|
| 52 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 53 |
-
|
| 54 |
-
console.log('Creating 15 conversations via API...');
|
| 55 |
-
|
| 56 |
-
// Step 2: Create 10 student conversations via API (mix of personality types)
|
| 57 |
-
const studentPersonalities = [
|
| 58 |
-
'ruirui',
|
| 59 |
-
'xiaoxu',
|
| 60 |
-
'xiaoen',
|
| 61 |
-
'xiaojie',
|
| 62 |
-
];
|
| 63 |
-
|
| 64 |
-
const studentConvIds = [];
|
| 65 |
-
for (let i = 0; i < 10; i++) {
|
| 66 |
-
const personality = studentPersonalities[i % 4];
|
| 67 |
-
const convId = await createConversationViaAPI(testUser, personality);
|
| 68 |
-
studentConvIds.push(convId);
|
| 69 |
-
console.log(`Created student conversation ${i + 1}/10: ${convId}`);
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
// Step 3: Create 5 coach-direct conversations via API
|
| 73 |
-
const coachConvIds = [];
|
| 74 |
-
const coaches = ['satir', 'satir', 'satir'];
|
| 75 |
-
for (let i = 0; i < 5; i++) {
|
| 76 |
-
const coachPromptId = coaches[i % 3];
|
| 77 |
-
const convId = await createConversationViaAPI(testUser, 'coach_direct', coachPromptId, `教練回顧 ${i + 1}`);
|
| 78 |
-
coachConvIds.push(convId);
|
| 79 |
-
console.log(`Created coach conversation ${i + 1}/5: ${convId}`);
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
console.log('✅ All 15 conversations created');
|
| 83 |
-
|
| 84 |
-
// Step 4: Refresh dashboard to load all conversations
|
| 85 |
-
await page.reload();
|
| 86 |
-
await page.waitForLoadState('networkidle');
|
| 87 |
-
|
| 88 |
-
// Test Case 1: Verify all 3 action cards are visible WITHOUT scrolling
|
| 89 |
-
console.log('Test 1: Verifying action cards are accessible without scrolling');
|
| 90 |
-
|
| 91 |
-
const newConvCard = page.locator('h3:has-text("指定對話演練")');
|
| 92 |
-
const continueConvCard = page.locator('h3:has-text("自訂對話演練")');
|
| 93 |
-
const coachCard = page.locator('h3:has-text("教練回顧")');
|
| 94 |
-
|
| 95 |
-
await expect(newConvCard).toBeVisible({ timeout: 10000 });
|
| 96 |
-
await expect(continueConvCard).toBeVisible({ timeout: 10000 });
|
| 97 |
-
await expect(coachCard).toBeVisible({ timeout: 10000 });
|
| 98 |
-
|
| 99 |
-
// Verify "自訂對話演練" card shows conversation count badge
|
| 100 |
-
// Try multiple selectors as badge format might vary
|
| 101 |
-
let conversationBadge = page.locator('text=/\\d+ 個對話/');
|
| 102 |
-
|
| 103 |
-
// If not found with exact pattern, try broader selectors
|
| 104 |
-
const badgeCount = await conversationBadge.count();
|
| 105 |
-
if (badgeCount === 0) {
|
| 106 |
-
// Try alternative badge locations
|
| 107 |
-
conversationBadge = continueConvCard.locator('text=/\\d+/').first();
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
await expect(conversationBadge).toBeVisible({ timeout: 5000 });
|
| 111 |
-
const badgeText = await conversationBadge.textContent();
|
| 112 |
-
console.log(`Dashboard shows: ${badgeText}`);
|
| 113 |
-
|
| 114 |
-
console.log('✅ All action cards visible and accessible');
|
| 115 |
-
|
| 116 |
-
// Test Case 2: Verify conversation list modal has scrolling with 10+ conversations
|
| 117 |
-
console.log('Test 2: Verifying conversation list scrolling');
|
| 118 |
-
|
| 119 |
-
await page.click('h3:has-text("自訂對話演練")');
|
| 120 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible();
|
| 121 |
-
|
| 122 |
-
// Count visible conversation items (should filter out coach-direct)
|
| 123 |
-
const conversationCards = page.locator('.space-y-3 > div');
|
| 124 |
-
const count = await conversationCards.count();
|
| 125 |
-
|
| 126 |
-
console.log(`✅ Conversation list shows ${count} conversations (should be 15 total: 10 student + 5 coach)`);
|
| 127 |
-
|
| 128 |
-
// Should show all 15 conversations (student + coach)
|
| 129 |
-
expect(count).toBe(15);
|
| 130 |
-
|
| 131 |
-
// Verify modal is scrollable by checking if last conversation is in view
|
| 132 |
-
const lastConvCard = conversationCards.last();
|
| 133 |
-
|
| 134 |
-
// Scroll to bottom
|
| 135 |
-
await lastConvCard.scrollIntoViewIfNeeded();
|
| 136 |
-
await expect(lastConvCard).toBeVisible();
|
| 137 |
-
|
| 138 |
-
console.log('✅ Conversation list is scrollable');
|
| 139 |
-
|
| 140 |
-
// Verify coach conversations ARE in the list (with coach emoji)
|
| 141 |
-
const coachConversations = page.locator('.space-y-3 > div').filter({ hasText: '👨🏫 諮詢教練' });
|
| 142 |
-
const coachCount = await coachConversations.count();
|
| 143 |
-
expect(coachCount).toBe(5); // Should have 5 coach conversations
|
| 144 |
-
|
| 145 |
-
console.log(`✅ Coach conversations shown in list: ${coachCount}`);
|
| 146 |
-
|
| 147 |
-
// Close modal by clicking backdrop
|
| 148 |
-
const modalBackdrop = page.locator('.fixed.inset-0.bg-black\\/50');
|
| 149 |
-
await modalBackdrop.click({ position: { x: 10, y: 10 } }); // Click top-left corner of backdrop
|
| 150 |
-
await expect(modalBackdrop).toHaveCount(0, { timeout: 5_000 });
|
| 151 |
-
|
| 152 |
-
// Test Case 3: Verify can create new conversation without scrolling
|
| 153 |
-
console.log('Test 3: Verifying new conversation creation');
|
| 154 |
-
|
| 155 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 156 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 157 |
-
|
| 158 |
-
// Select first student
|
| 159 |
-
const studentName = page.locator('text=睿睿').first();
|
| 160 |
-
await expect(studentName).toBeVisible();
|
| 161 |
-
await studentName.click();
|
| 162 |
-
|
| 163 |
-
// Should navigate to conversation page
|
| 164 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 165 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 166 |
-
|
| 167 |
-
console.log('✅ New conversation created successfully');
|
| 168 |
-
|
| 169 |
-
// Return to dashboard
|
| 170 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 171 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible({ timeout: 5000 });
|
| 172 |
-
|
| 173 |
-
// Test Case 4: Verify can access coach chat without scrolling
|
| 174 |
-
console.log('Test 4: Verifying coach chat access');
|
| 175 |
-
|
| 176 |
-
await page.click('h3:has-text("教練回顧")');
|
| 177 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 178 |
-
await page.click('button:has-text("開始諮詢")');
|
| 179 |
-
|
| 180 |
-
// Should navigate to conversation page (coach conversations now use regular conversation page)
|
| 181 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 182 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 183 |
-
|
| 184 |
-
console.log('✅ Coach conversation created and accessible without scrolling');
|
| 185 |
-
|
| 186 |
-
console.log('✅✅✅ All tests passed with 15 conversations!');
|
| 187 |
-
});
|
| 188 |
-
|
| 189 |
-
test('should verify sidebar with many conversations', async ({ page }) => {
|
| 190 |
-
test.setTimeout(120_000);
|
| 191 |
-
const testUser = `sidebar_many_${Date.now()}`;
|
| 192 |
-
|
| 193 |
-
// Register and login
|
| 194 |
-
await registerAndLogin(page, testUser);
|
| 195 |
-
|
| 196 |
-
console.log('Creating 12 student conversations...');
|
| 197 |
-
|
| 198 |
-
// Create 12 student conversations
|
| 199 |
-
for (let i = 0; i < 12; i++) {
|
| 200 |
-
const personality = i % 2 === 0 ? 'ruirui' : 'xiaoxu';
|
| 201 |
-
await createConversationViaAPI(testUser, personality);
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
// Create 3 coach-direct conversations
|
| 205 |
-
for (let i = 0; i < 3; i++) {
|
| 206 |
-
await createConversationViaAPI(testUser, 'coach_direct', 'satir', `教練回顧 ${i + 1}`);
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
console.log('✅ 15 conversations created');
|
| 210 |
-
|
| 211 |
-
// Refresh to load conversations
|
| 212 |
-
await page.reload();
|
| 213 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible({ timeout: 5000 });
|
| 214 |
-
|
| 215 |
-
// Create a conversation and navigate to it
|
| 216 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 217 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible({ timeout: 5000 });
|
| 218 |
-
const studentName = page.locator('text=睿睿').first();
|
| 219 |
-
await studentName.click();
|
| 220 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 221 |
-
|
| 222 |
-
// Wait for conversation page to load (wait for header to be visible)
|
| 223 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 224 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 225 |
-
|
| 226 |
-
// Open sidebar (look for hamburger button more specifically)
|
| 227 |
-
const hamburgerButton = page.locator('button:has(svg)').first();
|
| 228 |
-
await hamburgerButton.click();
|
| 229 |
-
|
| 230 |
-
// Verify sidebar shows conversations
|
| 231 |
-
await expect(page.getByRole('heading', { name: /對話記錄/i })).toBeVisible({ timeout: 3000 });
|
| 232 |
-
|
| 233 |
-
// Count conversations in sidebar - they should be in .space-y-1 container
|
| 234 |
-
// Each conversation is a div.relative.group within that container
|
| 235 |
-
const sidebarConvs = page.locator('.space-y-1 > div.relative.group');
|
| 236 |
-
|
| 237 |
-
// Wait for at least one conversation to be visible
|
| 238 |
-
await expect(sidebarConvs.first()).toBeVisible({ timeout: 10_000 });
|
| 239 |
-
|
| 240 |
-
const sidebarCount = await sidebarConvs.count();
|
| 241 |
-
|
| 242 |
-
console.log(`Sidebar shows ${sidebarCount} conversation items`);
|
| 243 |
-
|
| 244 |
-
// Should show 15+ conversations (12 student + 3 coach, plus 1 new student = 16)
|
| 245 |
-
expect(sidebarCount).toBeGreaterThanOrEqual(15);
|
| 246 |
-
|
| 247 |
-
console.log(`✅ Sidebar shows ${sidebarCount} conversations (student + coach)`);
|
| 248 |
-
|
| 249 |
-
// Verify sidebar is scrollable (if we have many conversations)
|
| 250 |
-
if (sidebarCount > 5) {
|
| 251 |
-
console.log('✅ Sidebar is scrollable (has enough conversations)');
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
// Close sidebar by clicking overlay
|
| 255 |
-
await page.click('.fixed.inset-0.bg-black\\/50');
|
| 256 |
-
await expect(page.locator('.fixed.inset-0.bg-black\\/50')).not.toBeVisible({ timeout: 2000 });
|
| 257 |
-
|
| 258 |
-
console.log('✅✅✅ Sidebar tests passed with many conversations!');
|
| 259 |
-
});
|
| 260 |
-
|
| 261 |
-
test('should handle navigation between conversations with high count', async ({ page }) => {
|
| 262 |
-
test.setTimeout(120_000);
|
| 263 |
-
const testUser = `nav_many_${Date.now()}`;
|
| 264 |
-
|
| 265 |
-
// Register and login
|
| 266 |
-
await registerAndLogin(page, testUser);
|
| 267 |
-
|
| 268 |
-
console.log('Creating 8 conversations...');
|
| 269 |
-
|
| 270 |
-
// Create 8 student conversations
|
| 271 |
-
const convIds = [];
|
| 272 |
-
for (let i = 0; i < 8; i++) {
|
| 273 |
-
const personality = ['ruirui', 'xiaoxu', 'xiaoen', 'xiaojie'][i % 4];
|
| 274 |
-
const convId = await createConversationViaAPI(testUser, personality);
|
| 275 |
-
convIds.push(convId);
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
console.log('✅ 8 conversations created');
|
| 279 |
-
|
| 280 |
-
// Refresh dashboard
|
| 281 |
-
await page.reload();
|
| 282 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible({ timeout: 5000 });
|
| 283 |
-
|
| 284 |
-
// Open conversation list
|
| 285 |
-
await page.click('h3:has-text("自訂對話演練")');
|
| 286 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible();
|
| 287 |
-
|
| 288 |
-
// Navigate to first conversation
|
| 289 |
-
const firstConv = page.locator('.space-y-3 > div').first();
|
| 290 |
-
await firstConv.click();
|
| 291 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 292 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 293 |
-
|
| 294 |
-
console.log('✅ Navigated to first conversation');
|
| 295 |
-
|
| 296 |
-
// Go back to dashboard
|
| 297 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 298 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible({ timeout: 5000 });
|
| 299 |
-
|
| 300 |
-
// Open conversation list again
|
| 301 |
-
await page.click('h3:has-text("自訂對話演練")');
|
| 302 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible();
|
| 303 |
-
|
| 304 |
-
// Scroll to bottom and click last conversation
|
| 305 |
-
const lastConv = page.locator('.space-y-3 > div').last();
|
| 306 |
-
await lastConv.scrollIntoViewIfNeeded();
|
| 307 |
-
await lastConv.click();
|
| 308 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 309 |
-
|
| 310 |
-
console.log('✅ Navigated to last conversation after scrolling');
|
| 311 |
-
|
| 312 |
-
// Verify all action cards still work from dashboard
|
| 313 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 314 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible({ timeout: 5000 });
|
| 315 |
-
|
| 316 |
-
// Verify new conversation still accessible
|
| 317 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 318 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 319 |
-
|
| 320 |
-
// Close modal by clicking backdrop
|
| 321 |
-
await page.click('.fixed.inset-0.bg-black\\/50');
|
| 322 |
-
await expect(page.locator('h3:has-text("選擇學生")')).not.toBeVisible({ timeout: 2000 });
|
| 323 |
-
|
| 324 |
-
// Verify coach chat still accessible
|
| 325 |
-
await page.click('h3:has-text("教練回顧")');
|
| 326 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible();
|
| 327 |
-
|
| 328 |
-
// Close modal
|
| 329 |
-
await page.click('button:has-text("取消")');
|
| 330 |
-
await expect(page.locator('text=開始諮詢')).not.toBeVisible({ timeout: 2000 });
|
| 331 |
-
|
| 332 |
-
console.log('✅✅✅ Navigation tests passed with many conversations!');
|
| 333 |
-
});
|
| 334 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/dashboard.spec.ts
DELETED
|
@@ -1,219 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
test.describe('Dashboard Login Flow', () => {
|
| 15 |
-
test('should show dashboard after login with 3 action cards', async ({ page }) => {
|
| 16 |
-
test.setTimeout(60_000);
|
| 17 |
-
const testUser = `dashboard_${Date.now()}`;
|
| 18 |
-
|
| 19 |
-
// 1. Register new user
|
| 20 |
-
await registerAndLogin(page, testUser);
|
| 21 |
-
|
| 22 |
-
// 2. Verify dashboard header
|
| 23 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 24 |
-
|
| 25 |
-
// 3. Verify welcome message with username
|
| 26 |
-
await expect(page.locator(`text=你好,${testUser}!`)).toBeVisible();
|
| 27 |
-
|
| 28 |
-
// 4. Verify 2 action cards are visible (Continue Conversation card is hidden)
|
| 29 |
-
await expect(page.locator('h3:has-text("指定對話演練")')).toBeVisible();
|
| 30 |
-
await expect(page.locator('h3:has-text("教練回顧")')).toBeVisible();
|
| 31 |
-
|
| 32 |
-
// 5. Verify card descriptions
|
| 33 |
-
await expect(page.locator('text=選擇一位學生,開始全新的對話練習')).toBeVisible();
|
| 34 |
-
await expect(page.locator('text=基於過去 25 天對話,獲取專業建議')).toBeVisible();
|
| 35 |
-
});
|
| 36 |
-
|
| 37 |
-
test('should open new conversation modal and show student selection', async ({ page }) => {
|
| 38 |
-
test.setTimeout(60_000);
|
| 39 |
-
const testUser = `modal_test_${Date.now()}`;
|
| 40 |
-
|
| 41 |
-
// Register and login
|
| 42 |
-
await registerAndLogin(page, testUser);
|
| 43 |
-
|
| 44 |
-
// Click "新對話" card
|
| 45 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 46 |
-
|
| 47 |
-
// Verify modal appears
|
| 48 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 49 |
-
await expect(page.locator('text=點選學生即可開始對話練習')).toBeVisible();
|
| 50 |
-
|
| 51 |
-
// Verify students are listed (should have at least 3 with 🎓 emoji)
|
| 52 |
-
const studentCards = page.locator('div').filter({ hasText: '🎓' });
|
| 53 |
-
await expect(studentCards.first()).toBeVisible();
|
| 54 |
-
|
| 55 |
-
// Verify student names are present (Chinese names: 睿睿, 小許, 小恩, 睿睿)
|
| 56 |
-
const hasXiaoMing = await page.locator('text=睿睿').isVisible();
|
| 57 |
-
const hasXiaoHua = await page.locator('text=小許').isVisible();
|
| 58 |
-
const hasXiaoMei = await page.locator('text=小恩').isVisible();
|
| 59 |
-
const hasXiaoAn = await page.locator('text=睿睿').isVisible();
|
| 60 |
-
expect(hasXiaoMing || hasXiaoHua || hasXiaoMei || hasXiaoAn).toBeTruthy();
|
| 61 |
-
|
| 62 |
-
// Close modal by clicking backdrop
|
| 63 |
-
await page.click('.fixed.inset-0.bg-black\\/50');
|
| 64 |
-
await expect(page.locator('h3:has-text("選擇學生")')).not.toBeVisible({ timeout: 5_000 });
|
| 65 |
-
});
|
| 66 |
-
|
| 67 |
-
test('should open conversation history modal from sidebar (empty for new user)', async ({ page }) => {
|
| 68 |
-
test.setTimeout(60_000);
|
| 69 |
-
const testUser = `history_test_${Date.now()}`;
|
| 70 |
-
|
| 71 |
-
// Register and login
|
| 72 |
-
await registerAndLogin(page, testUser);
|
| 73 |
-
|
| 74 |
-
// Open sidebar via hamburger menu
|
| 75 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 76 |
-
|
| 77 |
-
// Sidebar should show "尚無對話記錄" for new user
|
| 78 |
-
await expect(page.locator('text=尚無對話記錄')).toBeVisible({ timeout: 2000 });
|
| 79 |
-
|
| 80 |
-
// Close sidebar
|
| 81 |
-
await page.keyboard.press('Escape');
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
test('should open direct coach modal', async ({ page }) => {
|
| 85 |
-
test.setTimeout(60_000);
|
| 86 |
-
const testUser = `coach_modal_${Date.now()}`;
|
| 87 |
-
|
| 88 |
-
// Register and login
|
| 89 |
-
await registerAndLogin(page, testUser);
|
| 90 |
-
|
| 91 |
-
// Click "教練回顧" card
|
| 92 |
-
await page.click('h3:has-text("教練回顧")');
|
| 93 |
-
|
| 94 |
-
// Verify modal appears with more specific selector
|
| 95 |
-
await expect(page.locator('text=系統將基於您過去 25 天的所有對話記錄,為您提供綜合建議')).toBeVisible();
|
| 96 |
-
await expect(page.locator('button:has-text("開始諮詢")')).toBeVisible();
|
| 97 |
-
await expect(page.locator('button:has-text("取消")')).toBeVisible();
|
| 98 |
-
|
| 99 |
-
// Close modal using cancel button
|
| 100 |
-
await page.click('button:has-text("取消")');
|
| 101 |
-
await expect(page.locator('text=開始諮詢')).not.toBeVisible({ timeout: 2000 });
|
| 102 |
-
});
|
| 103 |
-
|
| 104 |
-
test('should create conversation from dashboard and navigate to chat', async ({ page }) => {
|
| 105 |
-
test.setTimeout(60_000);
|
| 106 |
-
const testUser = `create_conv_${Date.now()}`;
|
| 107 |
-
|
| 108 |
-
// Register and login
|
| 109 |
-
await registerAndLogin(page, testUser);
|
| 110 |
-
|
| 111 |
-
// Click "新對話" card
|
| 112 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 113 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 114 |
-
|
| 115 |
-
// Find student name directly and click on the parent card
|
| 116 |
-
const studentName = page.locator('text=睿睿').first();
|
| 117 |
-
await expect(studentName).toBeVisible({ timeout: 5000 });
|
| 118 |
-
|
| 119 |
-
// Click on the student name's parent container (the actual clickable card)
|
| 120 |
-
await studentName.click();
|
| 121 |
-
|
| 122 |
-
// Should navigate to conversation page
|
| 123 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 124 |
-
|
| 125 |
-
// Verify conversation page loaded
|
| 126 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 127 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
-
test('should show conversation in history after creation', async ({ page }) => {
|
| 131 |
-
test.setTimeout(60_000);
|
| 132 |
-
const testUser = `conv_history_${Date.now()}`;
|
| 133 |
-
|
| 134 |
-
// Register and login
|
| 135 |
-
await registerAndLogin(page, testUser);
|
| 136 |
-
|
| 137 |
-
// First create a conversation
|
| 138 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 139 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 140 |
-
|
| 141 |
-
// Click on student name to create conversation
|
| 142 |
-
const studentName = page.locator('text=睿睿').first();
|
| 143 |
-
await expect(studentName).toBeVisible({ timeout: 5000 });
|
| 144 |
-
await studentName.click();
|
| 145 |
-
|
| 146 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 147 |
-
|
| 148 |
-
// Wait for conversation page to fully load and ensure conversation is saved
|
| 149 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 150 |
-
await expect(page.locator('input#chat-input')).toBeVisible({ timeout: 5000 });
|
| 151 |
-
|
| 152 |
-
// Go back to dashboard
|
| 153 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 154 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 155 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible({ timeout: 5000 });
|
| 156 |
-
|
| 157 |
-
// Open sidebar to check conversation history
|
| 158 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 159 |
-
|
| 160 |
-
// Should now show conversations in sidebar (not empty state)
|
| 161 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible({ timeout: 2000 });
|
| 162 |
-
|
| 163 |
-
// Verify conversation exists by checking for student name in sidebar
|
| 164 |
-
const hasConversation = await page.locator('text=睿睿').first().isVisible({ timeout: 5_000 }).catch(() => false);
|
| 165 |
-
expect(hasConversation).toBeTruthy();
|
| 166 |
-
});
|
| 167 |
-
|
| 168 |
-
test('should navigate to coach chat from dashboard', async ({ page }) => {
|
| 169 |
-
test.setTimeout(60_000);
|
| 170 |
-
const testUser = `coach_nav_${Date.now()}`;
|
| 171 |
-
|
| 172 |
-
// Register and login
|
| 173 |
-
await registerAndLogin(page, testUser);
|
| 174 |
-
|
| 175 |
-
// Click "教練回顧" card
|
| 176 |
-
await page.click('h3:has-text("教練回顧")');
|
| 177 |
-
|
| 178 |
-
// Wait for modal to appear
|
| 179 |
-
await expect(page.locator('text=系統將基於您過去 25 天的所有對話記錄,為您提供綜合建議')).toBeVisible();
|
| 180 |
-
|
| 181 |
-
// Click "開始諮詢" button
|
| 182 |
-
await page.click('button:has-text("開始諮詢")');
|
| 183 |
-
|
| 184 |
-
// Should navigate to conversation page (coach conversations now use regular conversation page)
|
| 185 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 186 |
-
});
|
| 187 |
-
|
| 188 |
-
test('should navigate back to dashboard using sidebar home button', async ({ page }) => {
|
| 189 |
-
test.setTimeout(60_000);
|
| 190 |
-
const testUser = `sidebar_home_${Date.now()}`;
|
| 191 |
-
|
| 192 |
-
// Register and login
|
| 193 |
-
await registerAndLogin(page, testUser);
|
| 194 |
-
|
| 195 |
-
// Create a conversation and navigate to it
|
| 196 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 197 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible({ timeout: 5000 });
|
| 198 |
-
await page.click('text=睿睿');
|
| 199 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 200 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 201 |
-
|
| 202 |
-
// Open sidebar
|
| 203 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 204 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible({ timeout: 2000 });
|
| 205 |
-
|
| 206 |
-
// Verify home button exists
|
| 207 |
-
const homeButton = page.locator('button:has-text("回到首頁")');
|
| 208 |
-
await expect(homeButton).toBeVisible();
|
| 209 |
-
|
| 210 |
-
// Click home button
|
| 211 |
-
await homeButton.click();
|
| 212 |
-
|
| 213 |
-
// Should navigate back to dashboard
|
| 214 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 215 |
-
|
| 216 |
-
// Sidebar should be closed
|
| 217 |
-
await expect(page.locator('text=選單')).not.toBeVisible();
|
| 218 |
-
});
|
| 219 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/message-filter.spec.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// SKIPPED: Filter button is hidden in UI
|
| 6 |
-
test.describe.skip('Message Filter Feature', () => {
|
| 7 |
-
let sharedUser: string;
|
| 8 |
-
let sharedConversationCreated = false;
|
| 9 |
-
|
| 10 |
-
test('should filter messages between student and coach', async ({ page }) => {
|
| 11 |
-
sharedUser = `filter_test_${Date.now()}`;
|
| 12 |
-
test.setTimeout(60_000); // 2 minutes for LLM calls
|
| 13 |
-
|
| 14 |
-
// 1. Register and login
|
| 15 |
-
await page.goto(`${baseURL}/login`);
|
| 16 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 17 |
-
await page.fill('input[name="username"]', sharedUser);
|
| 18 |
-
await page.click('button:has-text("建立帳號")');
|
| 19 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 20 |
-
|
| 21 |
-
// 2. Create a new conversation
|
| 22 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 23 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 24 |
-
await page.waitForTimeout(1000);
|
| 25 |
-
const studentName = page.locator('text=睿睿').first();
|
| 26 |
-
await expect(studentName).toBeVisible();
|
| 27 |
-
await studentName.click();
|
| 28 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 29 |
-
|
| 30 |
-
// 3. Wait for conversation to load
|
| 31 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 32 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 33 |
-
|
| 34 |
-
// 4. Verify filter button exists in header
|
| 35 |
-
const filterButton = page.locator('button[title="篩選訊息"]');
|
| 36 |
-
await expect(filterButton).toBeVisible();
|
| 37 |
-
|
| 38 |
-
// 5. Send a student message
|
| 39 |
-
const studentButton = page.locator('button').filter({ hasText: '🎓' }).first();
|
| 40 |
-
await studentButton.click();
|
| 41 |
-
const input = page.locator('input#chat-input');
|
| 42 |
-
await input.fill('你好睿睿');
|
| 43 |
-
await input.press('Enter');
|
| 44 |
-
|
| 45 |
-
// Wait for student response
|
| 46 |
-
await page.waitForTimeout(2000);
|
| 47 |
-
await expect(page.locator('div').filter({ hasText: '🎓' }).first()).toBeVisible({ timeout: 60_000 });
|
| 48 |
-
|
| 49 |
-
// 6. Send a coach message (asking for feedback on teaching)
|
| 50 |
-
const coachButton = page.locator('button').filter({ hasText: '👨🏫' }).first();
|
| 51 |
-
await coachButton.click();
|
| 52 |
-
await page.waitForTimeout(500);
|
| 53 |
-
await input.fill('請給我教學建議');
|
| 54 |
-
await input.press('Enter');
|
| 55 |
-
|
| 56 |
-
// Wait for coach response
|
| 57 |
-
await page.waitForTimeout(2000);
|
| 58 |
-
await expect(page.locator('div').filter({ hasText: '👨🏫' }).first()).toBeVisible({ timeout: 60_000 });
|
| 59 |
-
|
| 60 |
-
// Wait a bit to ensure all messages are rendered
|
| 61 |
-
await page.waitForTimeout(2000);
|
| 62 |
-
|
| 63 |
-
// 7. Test filter button click (use JavaScript click to bypass overlay)
|
| 64 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 65 |
-
await page.waitForTimeout(500); // Wait for menu to appear
|
| 66 |
-
await expect(page.locator('text=📋 所有訊息')).toBeVisible();
|
| 67 |
-
await expect(page.locator('text=🎓 學生訊息')).toBeVisible();
|
| 68 |
-
await expect(page.locator('text=👨🏫 老師訊息')).toBeVisible();
|
| 69 |
-
|
| 70 |
-
// 8. Test filtering to student messages only
|
| 71 |
-
await page.click('text=🎓 學生訊息');
|
| 72 |
-
await page.waitForTimeout(500);
|
| 73 |
-
|
| 74 |
-
// Verify filter is active (yellow indicator dot should be visible)
|
| 75 |
-
const filterIndicator = page.locator('button[title="篩選訊息"] .bg-yellow-400');
|
| 76 |
-
await expect(filterIndicator).toBeVisible();
|
| 77 |
-
|
| 78 |
-
// Count message bubbles - should only show student messages
|
| 79 |
-
const studentBubbles = page.locator('div').filter({ hasText: '🎓' });
|
| 80 |
-
const coachBubbles = page.locator('div').filter({ hasText: '👨🏫' });
|
| 81 |
-
|
| 82 |
-
// Student messages should be visible
|
| 83 |
-
const studentCount = await studentBubbles.count();
|
| 84 |
-
expect(studentCount).toBeGreaterThan(0);
|
| 85 |
-
|
| 86 |
-
// 9. Test filtering to coach messages only
|
| 87 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 88 |
-
await page.waitForTimeout(300);
|
| 89 |
-
await page.click('text=👨🏫 老師訊息');
|
| 90 |
-
await page.waitForTimeout(500);
|
| 91 |
-
|
| 92 |
-
// Verify filter indicator still visible
|
| 93 |
-
await expect(filterIndicator).toBeVisible();
|
| 94 |
-
|
| 95 |
-
// 10. Test filtering back to all messages
|
| 96 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 97 |
-
await page.waitForTimeout(300);
|
| 98 |
-
await page.click('text=📋 所有訊息');
|
| 99 |
-
await page.waitForTimeout(500);
|
| 100 |
-
|
| 101 |
-
// Filter indicator should not be visible when showing all
|
| 102 |
-
await expect(filterIndicator).not.toBeVisible();
|
| 103 |
-
|
| 104 |
-
// Both student and coach messages should be visible
|
| 105 |
-
const allStudentBubbles = await page.locator('div').filter({ hasText: '🎓' }).count();
|
| 106 |
-
const allCoachBubbles = await page.locator('div').filter({ hasText: '👨🏫' }).count();
|
| 107 |
-
|
| 108 |
-
expect(allStudentBubbles).toBeGreaterThan(0);
|
| 109 |
-
expect(allCoachBubbles).toBeGreaterThan(0);
|
| 110 |
-
});
|
| 111 |
-
|
| 112 |
-
test('should close filter menu when clicking outside', async ({ page }) => {
|
| 113 |
-
test.setTimeout(60_000); // 1 minute
|
| 114 |
-
|
| 115 |
-
// Login with shared user
|
| 116 |
-
await page.goto(`${baseURL}/login`);
|
| 117 |
-
await page.fill('input[name="username"]', sharedUser);
|
| 118 |
-
await page.fill('input[name="password"]', 'cz-2025');
|
| 119 |
-
await page.click('button:has-text("登入")');
|
| 120 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 121 |
-
|
| 122 |
-
// Navigate to existing conversation
|
| 123 |
-
await page.waitForTimeout(1000);
|
| 124 |
-
await page.click('h3:has-text("自訂對話演練")');
|
| 125 |
-
await page.waitForTimeout(500);
|
| 126 |
-
const firstConv = page.locator('text=睿睿').first();
|
| 127 |
-
await firstConv.click();
|
| 128 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 129 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 130 |
-
|
| 131 |
-
// Open filter menu
|
| 132 |
-
const filterButton = page.locator('button[title="篩選訊息"]');
|
| 133 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 134 |
-
await page.waitForTimeout(300);
|
| 135 |
-
await expect(page.locator('text=📋 所有訊息')).toBeVisible();
|
| 136 |
-
|
| 137 |
-
// Click outside the menu (click on the header area)
|
| 138 |
-
await page.locator('h1').click();
|
| 139 |
-
await page.waitForTimeout(300);
|
| 140 |
-
|
| 141 |
-
// Menu should be closed
|
| 142 |
-
await expect(page.locator('text=📋 所有訊息')).not.toBeVisible();
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
test('should show empty state when no messages match filter', async ({ page }) => {
|
| 146 |
-
test.setTimeout(60_000); // 2 minutes for LLM call
|
| 147 |
-
|
| 148 |
-
// Login with shared user
|
| 149 |
-
await page.goto(`${baseURL}/login`);
|
| 150 |
-
await page.fill('input[name="username"]', sharedUser);
|
| 151 |
-
await page.fill('input[name="password"]', 'cz-2025');
|
| 152 |
-
await page.click('button:has-text("登入")');
|
| 153 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 154 |
-
|
| 155 |
-
// Create a new conversation
|
| 156 |
-
await page.waitForTimeout(1000);
|
| 157 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 158 |
-
await page.waitForTimeout(1000);
|
| 159 |
-
const studentName = page.locator('text=小許').first();
|
| 160 |
-
await expect(studentName).toBeVisible();
|
| 161 |
-
await studentName.click();
|
| 162 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 15_000 });
|
| 163 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 164 |
-
|
| 165 |
-
// Send one coach message first (so we have messages but can filter for empty student messages)
|
| 166 |
-
const coachButton = page.locator('button').filter({ hasText: '👨🏫' }).first();
|
| 167 |
-
await coachButton.click();
|
| 168 |
-
await page.waitForTimeout(500);
|
| 169 |
-
const input = page.locator('input#chat-input');
|
| 170 |
-
await input.fill('測試訊息');
|
| 171 |
-
await input.press('Enter');
|
| 172 |
-
await page.waitForTimeout(2000);
|
| 173 |
-
|
| 174 |
-
// Open filter menu and select student messages (should be empty)
|
| 175 |
-
const filterButton = page.locator('button[title="篩選訊息"]');
|
| 176 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 177 |
-
await page.waitForTimeout(300);
|
| 178 |
-
await page.click('text=🎓 學生訊息');
|
| 179 |
-
await page.waitForTimeout(500);
|
| 180 |
-
|
| 181 |
-
// Should show empty state for student messages (since we only sent coach message)
|
| 182 |
-
await page.waitForTimeout(500); // Wait for empty state to render
|
| 183 |
-
await expect(page.locator('text=沒有學生訊息')).toBeVisible();
|
| 184 |
-
await expect(page.locator('text=🔍')).toBeVisible();
|
| 185 |
-
|
| 186 |
-
// Switch to coach filter (should show the coach message)
|
| 187 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 188 |
-
await page.waitForTimeout(300);
|
| 189 |
-
await page.click('text=👨🏫 老師訊息');
|
| 190 |
-
await page.waitForTimeout(500);
|
| 191 |
-
|
| 192 |
-
// Should show coach message (not empty)
|
| 193 |
-
const coachMessages = await page.locator('div').filter({ hasText: '👨🏫' }).count();
|
| 194 |
-
expect(coachMessages).toBeGreaterThan(0);
|
| 195 |
-
});
|
| 196 |
-
|
| 197 |
-
test('should maintain filter state during conversation', async ({ page }) => {
|
| 198 |
-
test.setTimeout(60_000); // 1 minute
|
| 199 |
-
|
| 200 |
-
// Login with shared user
|
| 201 |
-
await page.goto(`${baseURL}/login`);
|
| 202 |
-
await page.fill('input[name="username"]', sharedUser);
|
| 203 |
-
await page.fill('input[name="password"]', 'cz-2025');
|
| 204 |
-
await page.click('button:has-text("登入")');
|
| 205 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 206 |
-
|
| 207 |
-
// Navigate to conversation with messages
|
| 208 |
-
await page.waitForTimeout(1000);
|
| 209 |
-
await page.click('h3:has-text("自訂對話演練")');
|
| 210 |
-
await page.waitForTimeout(500);
|
| 211 |
-
const firstConv = page.locator('text=睿睿').first();
|
| 212 |
-
await firstConv.click();
|
| 213 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 214 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 215 |
-
|
| 216 |
-
// Apply student filter
|
| 217 |
-
const filterButton = page.locator('button[title="篩選訊息"]');
|
| 218 |
-
await filterButton.evaluate((el) => (el as HTMLElement).click());
|
| 219 |
-
await page.waitForTimeout(300);
|
| 220 |
-
await page.click('text=🎓 學生訊息');
|
| 221 |
-
await page.waitForTimeout(500);
|
| 222 |
-
|
| 223 |
-
// Verify filter indicator is visible
|
| 224 |
-
const filterIndicator = page.locator('button[title="篩選訊息"] .bg-yellow-400');
|
| 225 |
-
await expect(filterIndicator).toBeVisible();
|
| 226 |
-
|
| 227 |
-
// Verify only student messages are shown (should see student avatar)
|
| 228 |
-
const studentBubbles = await page.locator('div').filter({ hasText: '🎓' }).count();
|
| 229 |
-
expect(studentBubbles).toBeGreaterThan(0);
|
| 230 |
-
});
|
| 231 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/prompts-in-conversations.spec.ts
DELETED
|
@@ -1,343 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
const PW = 'cz-2025';
|
| 5 |
-
const authHeader = (u: string) => 'Basic ' + Buffer.from(`${u}:${PW}`).toString('base64');
|
| 6 |
-
|
| 7 |
-
test.describe('Database-Backed Prompts in Conversations', () => {
|
| 8 |
-
test('should create conversation with DB-backed student prompt', async ({ request }) => {
|
| 9 |
-
test.setTimeout(90_000);
|
| 10 |
-
|
| 11 |
-
const testUser = `prompt_test_${Date.now()}`;
|
| 12 |
-
|
| 13 |
-
// Register user
|
| 14 |
-
const reg = await request.post(`${baseURL}/api/auth/register`, {
|
| 15 |
-
data: { username: testUser },
|
| 16 |
-
});
|
| 17 |
-
expect(reg.ok()).toBeTruthy();
|
| 18 |
-
|
| 19 |
-
// Create conversation with student prompt from database
|
| 20 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 21 |
-
headers: {
|
| 22 |
-
Authorization: authHeader(testUser),
|
| 23 |
-
'Content-Type': 'application/json',
|
| 24 |
-
},
|
| 25 |
-
data: {
|
| 26 |
-
studentPromptId: 'ruirui',
|
| 27 |
-
coachPromptId: 'satir',
|
| 28 |
-
include3ConversationSummary: false,
|
| 29 |
-
},
|
| 30 |
-
});
|
| 31 |
-
|
| 32 |
-
expect(convResp.ok()).toBeTruthy();
|
| 33 |
-
const convData = await convResp.json();
|
| 34 |
-
|
| 35 |
-
// Verify conversation was created
|
| 36 |
-
expect(convData.conversation).toBeTruthy();
|
| 37 |
-
expect(convData.conversation.id).toBeTruthy();
|
| 38 |
-
expect(convData.conversation.studentPromptId).toBe('ruirui');
|
| 39 |
-
expect(convData.conversation.coachPromptId).toBe('satir');
|
| 40 |
-
|
| 41 |
-
// Verify systemPrompt is stored (from database)
|
| 42 |
-
expect(convData.conversation.systemPrompt).toBeTruthy();
|
| 43 |
-
expect(convData.conversation.systemPrompt.length).toBeGreaterThan(50);
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
test('should use DB prompt when sending messages in conversation', async ({ request }) => {
|
| 47 |
-
test.setTimeout(90_000);
|
| 48 |
-
|
| 49 |
-
const testUser = `prompt_msg_${Date.now()}`;
|
| 50 |
-
|
| 51 |
-
// Register user
|
| 52 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 53 |
-
|
| 54 |
-
// Create conversation
|
| 55 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 56 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 57 |
-
data: { studentPromptId: 'ruirui', coachPromptId: 'satir' },
|
| 58 |
-
});
|
| 59 |
-
const convData = await convResp.json();
|
| 60 |
-
const conversationId = convData.conversation.id;
|
| 61 |
-
|
| 62 |
-
// Send message using the conversation endpoint
|
| 63 |
-
const msgResp = await request.post(`${baseURL}/api/conversations/${conversationId}/message`, {
|
| 64 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 65 |
-
data: {
|
| 66 |
-
messages: [
|
| 67 |
-
{ id: '1', role: 'user', parts: [{ type: 'text', text: '你好,我是小明' }] },
|
| 68 |
-
],
|
| 69 |
-
},
|
| 70 |
-
timeout: 60_000,
|
| 71 |
-
});
|
| 72 |
-
|
| 73 |
-
// Streaming endpoint returns 200
|
| 74 |
-
expect(msgResp.status()).toBe(200);
|
| 75 |
-
expect(msgResp.headers()['content-type']).toContain('text/event-stream');
|
| 76 |
-
|
| 77 |
-
// Verify response has data
|
| 78 |
-
const msgBody = await msgResp.text();
|
| 79 |
-
expect(msgBody.length).toBeGreaterThan(0);
|
| 80 |
-
expect(msgBody).toContain('data:');
|
| 81 |
-
|
| 82 |
-
// The student should respond (indicating the prompt is working)
|
| 83 |
-
// Response should be in Traditional Chinese as specified in ruirui prompt
|
| 84 |
-
// We can't check the exact content since it's streaming, but we verified the format
|
| 85 |
-
});
|
| 86 |
-
|
| 87 |
-
test('should create conversation and verify prompt fields are derived', async ({ request }) => {
|
| 88 |
-
test.setTimeout(60_000);
|
| 89 |
-
|
| 90 |
-
const testUser = `prompt_derived_${Date.now()}`;
|
| 91 |
-
|
| 92 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 93 |
-
|
| 94 |
-
// Create conversation
|
| 95 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 96 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 97 |
-
data: { studentPromptId: 'xiaoxu', coachPromptId: 'satir' },
|
| 98 |
-
});
|
| 99 |
-
|
| 100 |
-
expect(convResp.ok()).toBeTruthy();
|
| 101 |
-
const convData = await convResp.json();
|
| 102 |
-
|
| 103 |
-
// Verify derived fields (studentName, coachName) are present
|
| 104 |
-
// These are NOT stored in DB, but derived from PromptService
|
| 105 |
-
expect(convData.conversation.studentName).toBeTruthy();
|
| 106 |
-
expect(convData.conversation.coachName).toBeTruthy();
|
| 107 |
-
expect(typeof convData.conversation.studentName).toBe('string');
|
| 108 |
-
expect(typeof convData.conversation.coachName).toBe('string');
|
| 109 |
-
});
|
| 110 |
-
|
| 111 |
-
test('should list conversations with derived prompt names', async ({ request }) => {
|
| 112 |
-
test.setTimeout(60_000);
|
| 113 |
-
|
| 114 |
-
const testUser = `prompt_list_${Date.now()}`;
|
| 115 |
-
|
| 116 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 117 |
-
|
| 118 |
-
// Create a conversation
|
| 119 |
-
await request.post(`${baseURL}/api/conversations/create`, {
|
| 120 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 121 |
-
data: { studentPromptId: 'xiaoen', coachPromptId: 'satir' },
|
| 122 |
-
});
|
| 123 |
-
|
| 124 |
-
// List conversations
|
| 125 |
-
const listResp = await request.get(`${baseURL}/api/conversations`, {
|
| 126 |
-
headers: { Authorization: authHeader(testUser) },
|
| 127 |
-
});
|
| 128 |
-
|
| 129 |
-
expect(listResp.ok()).toBeTruthy();
|
| 130 |
-
const listData = await listResp.json();
|
| 131 |
-
|
| 132 |
-
expect(listData.conversations).toBeTruthy();
|
| 133 |
-
expect(listData.conversations.length).toBeGreaterThan(0);
|
| 134 |
-
|
| 135 |
-
// Verify first conversation has derived fields
|
| 136 |
-
const conversation = listData.conversations[0];
|
| 137 |
-
expect(conversation.studentName).toBeTruthy();
|
| 138 |
-
expect(conversation.coachName).toBeTruthy();
|
| 139 |
-
});
|
| 140 |
-
|
| 141 |
-
test('should handle invalid student prompt ID gracefully', async ({ request }) => {
|
| 142 |
-
test.setTimeout(60_000);
|
| 143 |
-
|
| 144 |
-
const testUser = `prompt_invalid_${Date.now()}`;
|
| 145 |
-
|
| 146 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 147 |
-
|
| 148 |
-
// Try to create conversation with invalid student prompt ID
|
| 149 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 150 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 151 |
-
data: { studentPromptId: 'invalid_student_999', coachPromptId: 'satir' },
|
| 152 |
-
});
|
| 153 |
-
|
| 154 |
-
// Should return error
|
| 155 |
-
expect(convResp.status()).toBe(400);
|
| 156 |
-
const convData = await convResp.json();
|
| 157 |
-
expect(convData.error).toBeTruthy();
|
| 158 |
-
});
|
| 159 |
-
|
| 160 |
-
test('should handle invalid coach prompt ID gracefully', async ({ request }) => {
|
| 161 |
-
test.setTimeout(60_000);
|
| 162 |
-
|
| 163 |
-
const testUser = `prompt_invalid_coach_${Date.now()}`;
|
| 164 |
-
|
| 165 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 166 |
-
|
| 167 |
-
// Try to create conversation with invalid coach prompt ID
|
| 168 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 169 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 170 |
-
data: { studentPromptId: 'ruirui', coachPromptId: 'invalid_coach_999' },
|
| 171 |
-
});
|
| 172 |
-
|
| 173 |
-
// Should return error
|
| 174 |
-
expect(convResp.status()).toBe(400);
|
| 175 |
-
const convData = await convResp.json();
|
| 176 |
-
expect(convData.error).toBeTruthy();
|
| 177 |
-
});
|
| 178 |
-
|
| 179 |
-
test('should create new conversation after editing prompt in admin', async ({ request, page }) => {
|
| 180 |
-
test.setTimeout(120_000);
|
| 181 |
-
|
| 182 |
-
const testUser = `prompt_edit_test_${Date.now()}`;
|
| 183 |
-
const testPromptId = `test_edit_prompt_${Date.now()}`;
|
| 184 |
-
|
| 185 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 186 |
-
|
| 187 |
-
// 1. Create a test prompt via API
|
| 188 |
-
const createPromptResp = await request.post(`${baseURL}/api/prompts`, {
|
| 189 |
-
headers: { 'Content-Type': 'application/json' },
|
| 190 |
-
data: {
|
| 191 |
-
id: testPromptId,
|
| 192 |
-
type: 'student',
|
| 193 |
-
name: `測試學生 Original`,
|
| 194 |
-
description: '原始描述',
|
| 195 |
-
systemPrompt:
|
| 196 |
-
'IMPORTANT: You MUST respond in Traditional Chinese. You are a test student. Original version. Keep responses SHORT (5-7 words). This is for testing prompt management system.',
|
| 197 |
-
tools: ['calculate'],
|
| 198 |
-
},
|
| 199 |
-
});
|
| 200 |
-
expect(createPromptResp.ok()).toBeTruthy();
|
| 201 |
-
|
| 202 |
-
// 2. Create conversation with this prompt
|
| 203 |
-
const conv1Resp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 204 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 205 |
-
data: { studentPromptId: testPromptId, coachPromptId: 'satir' },
|
| 206 |
-
});
|
| 207 |
-
expect(conv1Resp.ok()).toBeTruthy();
|
| 208 |
-
const conv1Data = await conv1Resp.json();
|
| 209 |
-
const originalPrompt = conv1Data.conversation.systemPrompt;
|
| 210 |
-
expect(originalPrompt).toContain('Original version');
|
| 211 |
-
|
| 212 |
-
// 3. Edit the prompt via admin UI
|
| 213 |
-
await page.goto(`${baseURL}/admin/prompts/${testPromptId}`);
|
| 214 |
-
|
| 215 |
-
const nameInput = page.locator('input#name');
|
| 216 |
-
await expect(nameInput).toBeVisible({ timeout: 5000 });
|
| 217 |
-
await nameInput.fill(`測試學生 Updated`);
|
| 218 |
-
|
| 219 |
-
const systemPromptTextarea = page.locator('textarea#systemPrompt');
|
| 220 |
-
await systemPromptTextarea.fill(
|
| 221 |
-
'IMPORTANT: You MUST respond in Traditional Chinese. You are a test student. UPDATED version. Keep responses SHORT (5-7 words). This is for testing prompt management system updates.'
|
| 222 |
-
);
|
| 223 |
-
|
| 224 |
-
await page.click('button:has-text("儲存")');
|
| 225 |
-
await expect(page).toHaveURL(`${baseURL}/admin/prompts`, { timeout: 10_000 });
|
| 226 |
-
|
| 227 |
-
// Note: Cache has 5 min TTL, but new conversations will fetch fresh data from DB
|
| 228 |
-
|
| 229 |
-
// 4. Create NEW conversation with the updated prompt
|
| 230 |
-
const conv2Resp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 231 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 232 |
-
data: { studentPromptId: testPromptId, coachPromptId: 'satir' },
|
| 233 |
-
});
|
| 234 |
-
expect(conv2Resp.ok()).toBeTruthy();
|
| 235 |
-
const conv2Data = await conv2Resp.json();
|
| 236 |
-
const updatedPrompt = conv2Data.conversation.systemPrompt;
|
| 237 |
-
expect(updatedPrompt).toContain('UPDATED version');
|
| 238 |
-
expect(updatedPrompt).not.toContain('Original version');
|
| 239 |
-
|
| 240 |
-
// 5. Verify old conversation still has original prompt (stored in DB)
|
| 241 |
-
const getConv1Resp = await request.get(
|
| 242 |
-
`${baseURL}/api/conversations/${conv1Data.conversation.id}`,
|
| 243 |
-
{
|
| 244 |
-
headers: { Authorization: authHeader(testUser) },
|
| 245 |
-
}
|
| 246 |
-
);
|
| 247 |
-
expect(getConv1Resp.ok()).toBeTruthy();
|
| 248 |
-
const getConv1Data = await getConv1Resp.json();
|
| 249 |
-
expect(getConv1Data.conversation.systemPrompt).toContain('Original version');
|
| 250 |
-
|
| 251 |
-
// 6. Clean up: Delete the test prompt
|
| 252 |
-
const deleteResp = await request.delete(`${baseURL}/api/prompts/${testPromptId}`);
|
| 253 |
-
expect(deleteResp.ok()).toBeTruthy();
|
| 254 |
-
});
|
| 255 |
-
|
| 256 |
-
test('should continue working with deactivated prompt (existing conversations)', async ({ request }) => {
|
| 257 |
-
test.setTimeout(120_000);
|
| 258 |
-
|
| 259 |
-
const testUser = `prompt_deactivate_${Date.now()}`;
|
| 260 |
-
const testPromptId = `test_deactivate_${Date.now()}`;
|
| 261 |
-
|
| 262 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 263 |
-
|
| 264 |
-
// 1. Create a test prompt
|
| 265 |
-
await request.post(`${baseURL}/api/prompts`, {
|
| 266 |
-
headers: { 'Content-Type': 'application/json' },
|
| 267 |
-
data: {
|
| 268 |
-
id: testPromptId,
|
| 269 |
-
type: 'student',
|
| 270 |
-
name: `測試停用學生`,
|
| 271 |
-
systemPrompt:
|
| 272 |
-
'IMPORTANT: You MUST respond in Traditional Chinese. You are a test student for deactivation testing. Keep responses SHORT (5-7 words).',
|
| 273 |
-
tools: ['calculate'],
|
| 274 |
-
},
|
| 275 |
-
});
|
| 276 |
-
|
| 277 |
-
// 2. Create conversation with this prompt
|
| 278 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 279 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 280 |
-
data: { studentPromptId: testPromptId, coachPromptId: 'satir' },
|
| 281 |
-
});
|
| 282 |
-
expect(convResp.ok()).toBeTruthy();
|
| 283 |
-
const convData = await convResp.json();
|
| 284 |
-
const conversationId = convData.conversation.id;
|
| 285 |
-
|
| 286 |
-
// 3. Deactivate (soft delete) the prompt
|
| 287 |
-
const deleteResp = await request.delete(`${baseURL}/api/prompts/${testPromptId}`);
|
| 288 |
-
expect(deleteResp.ok()).toBeTruthy();
|
| 289 |
-
|
| 290 |
-
// 4. Verify existing conversation still works (uses stored systemPrompt)
|
| 291 |
-
const msgResp = await request.post(`${baseURL}/api/conversations/${conversationId}/message`, {
|
| 292 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 293 |
-
data: {
|
| 294 |
-
messages: [{ id: '1', role: 'user', parts: [{ type: 'text', text: '你好' }] }],
|
| 295 |
-
},
|
| 296 |
-
timeout: 60_000,
|
| 297 |
-
});
|
| 298 |
-
|
| 299 |
-
// Should still work because systemPrompt is stored
|
| 300 |
-
expect(msgResp.status()).toBe(200);
|
| 301 |
-
const msgBody = await msgResp.text();
|
| 302 |
-
expect(msgBody).toContain('data:');
|
| 303 |
-
|
| 304 |
-
// 5. Try to create NEW conversation with deactivated prompt
|
| 305 |
-
const newConvResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 306 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 307 |
-
data: { studentPromptId: testPromptId, coachPromptId: 'satir' },
|
| 308 |
-
});
|
| 309 |
-
|
| 310 |
-
// Should fail with 400 error
|
| 311 |
-
expect(newConvResp.status()).toBe(400);
|
| 312 |
-
const newConvData = await newConvResp.json();
|
| 313 |
-
expect(newConvData.error).toBeTruthy();
|
| 314 |
-
});
|
| 315 |
-
|
| 316 |
-
test('should use correct tools from database prompt', async ({ request }) => {
|
| 317 |
-
test.setTimeout(60_000);
|
| 318 |
-
|
| 319 |
-
const testUser = `prompt_tools_${Date.now()}`;
|
| 320 |
-
|
| 321 |
-
await request.post(`${baseURL}/api/auth/register`, { data: { username: testUser } });
|
| 322 |
-
|
| 323 |
-
// Create conversation with ruirui which has tools defined
|
| 324 |
-
const convResp = await request.post(`${baseURL}/api/conversations/create`, {
|
| 325 |
-
headers: { Authorization: authHeader(testUser), 'Content-Type': 'application/json' },
|
| 326 |
-
data: { studentPromptId: 'ruirui', coachPromptId: 'satir' },
|
| 327 |
-
});
|
| 328 |
-
|
| 329 |
-
expect(convResp.ok()).toBeTruthy();
|
| 330 |
-
const convData = await convResp.json();
|
| 331 |
-
|
| 332 |
-
// Verify systemPrompt contains tools ({{TOOLS}} placeholder should be interpolated)
|
| 333 |
-
const systemPrompt = convData.conversation.systemPrompt;
|
| 334 |
-
expect(systemPrompt).toBeTruthy();
|
| 335 |
-
|
| 336 |
-
// Should NOT contain the placeholder anymore (should be interpolated)
|
| 337 |
-
expect(systemPrompt).not.toContain('{{TOOLS}}');
|
| 338 |
-
|
| 339 |
-
// Should contain tool descriptions (if tools are defined in ruirui)
|
| 340 |
-
// This is indirect verification - we're checking that interpolation happened
|
| 341 |
-
expect(systemPrompt.length).toBeGreaterThan(100);
|
| 342 |
-
});
|
| 343 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/quoted-replies.spec.ts
DELETED
|
@@ -1,273 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
test.describe('Quoted Replies Feature', () => {
|
| 15 |
-
test.use({ workers: 1 }); // Run serially to avoid rate limits
|
| 16 |
-
|
| 17 |
-
test('should select text from student message and send quoted reply', async ({ page }) => {
|
| 18 |
-
test.setTimeout(90_000); // 90 seconds for LLM responses
|
| 19 |
-
const testUser = `quote-test-${Date.now()}`;
|
| 20 |
-
|
| 21 |
-
// Step 1: Register and create conversation with student
|
| 22 |
-
await registerAndLogin(page, testUser);
|
| 23 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 24 |
-
|
| 25 |
-
// Create student conversation
|
| 26 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 27 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 28 |
-
|
| 29 |
-
// Select 睿睿 (ADHD Inattentive)
|
| 30 |
-
await page.click('text=睿睿');
|
| 31 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 32 |
-
await expect(page.locator('h1')).toBeVisible();
|
| 33 |
-
|
| 34 |
-
console.log('✅ Student conversation created');
|
| 35 |
-
|
| 36 |
-
// Step 2: Send first message as student
|
| 37 |
-
await page.fill('input[type="text"]', 'I need help with my math homework. The problem is 25 + 37 and I am confused.');
|
| 38 |
-
await page.click('button[type="submit"]');
|
| 39 |
-
|
| 40 |
-
// Wait for student response
|
| 41 |
-
const assistantMessages = page.locator('.whitespace-pre-wrap');
|
| 42 |
-
await expect(assistantMessages.first()).toBeVisible({ timeout: 60000 });
|
| 43 |
-
|
| 44 |
-
console.log('✅ Student responded');
|
| 45 |
-
|
| 46 |
-
// Step 3: Select text from student's response using triple-click (more reliable in tests)
|
| 47 |
-
// Find the student's message bubble (white background with blue border)
|
| 48 |
-
const studentMessageBubble = page.locator('.bg-white.border.border-blue-100').first();
|
| 49 |
-
await expect(studentMessageBubble).toBeVisible();
|
| 50 |
-
|
| 51 |
-
// Get the text content
|
| 52 |
-
const messageText = await studentMessageBubble.textContent();
|
| 53 |
-
console.log('Student message:', messageText);
|
| 54 |
-
|
| 55 |
-
// Select text by triple-clicking (selects entire text, more reliable than drag)
|
| 56 |
-
const textElement = studentMessageBubble.locator('.whitespace-pre-wrap').first();
|
| 57 |
-
await textElement.click({ clickCount: 3 });
|
| 58 |
-
await expect(page.locator('.bg-gray-50.border-l-4.border-blue-500')).toBeVisible({ timeout: 2000 });
|
| 59 |
-
|
| 60 |
-
// Verify selection was created
|
| 61 |
-
const selectedText = await page.evaluate(() => {
|
| 62 |
-
return window.getSelection()?.toString() || '';
|
| 63 |
-
});
|
| 64 |
-
console.log('Selected text:', selectedText);
|
| 65 |
-
expect(selectedText.length).toBeGreaterThan(0);
|
| 66 |
-
|
| 67 |
-
// Step 4: Verify quote preview appears
|
| 68 |
-
const quotePreview = page.locator('.bg-gray-50.border-l-4.border-blue-500');
|
| 69 |
-
await expect(quotePreview).toBeVisible({ timeout: 5000 });
|
| 70 |
-
await expect(quotePreview.locator('text=回覆:')).toBeVisible();
|
| 71 |
-
|
| 72 |
-
console.log('✅ Quote preview displayed');
|
| 73 |
-
|
| 74 |
-
// Step 5: Switch to coach and send reply with quote
|
| 75 |
-
await page.click('button[title="詢問教練"]');
|
| 76 |
-
await page.fill('input[type="text"]', 'Can you help explain this to the student?');
|
| 77 |
-
await page.click('button[type="submit"]');
|
| 78 |
-
|
| 79 |
-
// Verify the quoted message appears in the chat
|
| 80 |
-
const quotedMessage = page.locator('[data-message-id]').filter({ has: page.locator('text=回覆:') }).first();
|
| 81 |
-
await expect(quotedMessage).toBeVisible({ timeout: 60000 });
|
| 82 |
-
|
| 83 |
-
console.log('✅ Message with quote sent successfully');
|
| 84 |
-
|
| 85 |
-
// Step 6: Verify quote is saved and displayed
|
| 86 |
-
const quotedTextDisplay = quotedMessage.locator('.border-l-2');
|
| 87 |
-
await expect(quotedTextDisplay).toBeVisible();
|
| 88 |
-
|
| 89 |
-
console.log('✅ Quoted text displayed in message bubble');
|
| 90 |
-
|
| 91 |
-
console.log('✅✅✅ Quoted reply test passed!');
|
| 92 |
-
});
|
| 93 |
-
|
| 94 |
-
test('should clear quote before sending', async ({ page }) => {
|
| 95 |
-
test.setTimeout(90_000);
|
| 96 |
-
const testUser = `quote-clear-${Date.now()}`;
|
| 97 |
-
|
| 98 |
-
// Register and create conversation
|
| 99 |
-
await registerAndLogin(page, testUser);
|
| 100 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 101 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 102 |
-
await page.click('text=睿睿');
|
| 103 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 104 |
-
|
| 105 |
-
console.log('✅ Conversation created');
|
| 106 |
-
|
| 107 |
-
// Send message and get response
|
| 108 |
-
await page.fill('input[type="text"]', 'Tell me about addition.');
|
| 109 |
-
await page.click('button[type="submit"]');
|
| 110 |
-
|
| 111 |
-
// Wait for response
|
| 112 |
-
const assistantMessages = page.locator('.whitespace-pre-wrap');
|
| 113 |
-
await expect(assistantMessages.first()).toBeVisible({ timeout: 60000 });
|
| 114 |
-
|
| 115 |
-
console.log('✅ Got student response');
|
| 116 |
-
|
| 117 |
-
// Select text using triple-click (more reliable than drag)
|
| 118 |
-
const studentMessageBubble = page.locator('.bg-white.border.border-blue-100').first();
|
| 119 |
-
const textElement = studentMessageBubble.locator('.whitespace-pre-wrap').first();
|
| 120 |
-
await textElement.click({ clickCount: 3 });
|
| 121 |
-
await expect(page.locator('.bg-gray-50.border-l-4.border-blue-500')).toBeVisible({ timeout: 2000 });
|
| 122 |
-
|
| 123 |
-
// Verify quote preview appears
|
| 124 |
-
const quotePreview = page.locator('.bg-gray-50.border-l-4.border-blue-500');
|
| 125 |
-
await expect(quotePreview).toBeVisible();
|
| 126 |
-
|
| 127 |
-
console.log('✅ Quote preview displayed');
|
| 128 |
-
|
| 129 |
-
// Click clear button
|
| 130 |
-
const clearButton = quotePreview.locator('button[aria-label="清除引用"]');
|
| 131 |
-
await expect(clearButton).toBeVisible();
|
| 132 |
-
await clearButton.click();
|
| 133 |
-
|
| 134 |
-
// Verify quote preview is gone
|
| 135 |
-
await expect(quotePreview).not.toBeVisible();
|
| 136 |
-
|
| 137 |
-
console.log('✅ Quote cleared successfully');
|
| 138 |
-
|
| 139 |
-
// Send message without quote
|
| 140 |
-
await page.fill('input[type="text"]', 'This message should have no quote.');
|
| 141 |
-
await page.click('button[type="submit"]');
|
| 142 |
-
await expect(page.locator('.bg-gradient-to-br.from-blue-500.to-blue-600').last()).toBeVisible({ timeout: 5000 });
|
| 143 |
-
|
| 144 |
-
// Verify message was sent without quoted text indicator
|
| 145 |
-
const lastUserMessage = page.locator('.bg-gradient-to-br.from-blue-500.to-blue-600').last();
|
| 146 |
-
await expect(lastUserMessage).toBeVisible();
|
| 147 |
-
|
| 148 |
-
// Should NOT have the "回覆:" label
|
| 149 |
-
const hasQuoteLabel = await lastUserMessage.locator('text=回覆:').count();
|
| 150 |
-
expect(hasQuoteLabel).toBe(0);
|
| 151 |
-
|
| 152 |
-
console.log('✅✅✅ Clear quote test passed!');
|
| 153 |
-
});
|
| 154 |
-
|
| 155 |
-
test('should persist quoted messages after page reload', async ({ page }) => {
|
| 156 |
-
test.setTimeout(90_000);
|
| 157 |
-
const testUser = `quote-persist-${Date.now()}`;
|
| 158 |
-
|
| 159 |
-
// Create conversation and send quoted reply
|
| 160 |
-
await registerAndLogin(page, testUser);
|
| 161 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 162 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 163 |
-
await page.click('text=小許');
|
| 164 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 165 |
-
|
| 166 |
-
console.log('✅ Conversation created');
|
| 167 |
-
|
| 168 |
-
// Send message
|
| 169 |
-
await page.fill('input[type="text"]', 'What is your favorite subject?');
|
| 170 |
-
await page.click('button[type="submit"]');
|
| 171 |
-
|
| 172 |
-
// Wait for response
|
| 173 |
-
await expect(page.locator('.whitespace-pre-wrap').first()).toBeVisible({ timeout: 60000 });
|
| 174 |
-
|
| 175 |
-
// Create a quote using triple-click (more reliable than drag)
|
| 176 |
-
const studentMessageBubble = page.locator('.bg-white.border.border-blue-100').first();
|
| 177 |
-
const textElement = studentMessageBubble.locator('.whitespace-pre-wrap').first();
|
| 178 |
-
await textElement.click({ clickCount: 3 });
|
| 179 |
-
await expect(page.locator('.bg-gray-50.border-l-4.border-blue-500')).toBeVisible({ timeout: 2000 });
|
| 180 |
-
|
| 181 |
-
// Send quoted reply
|
| 182 |
-
await page.click('button[title="詢問教練"]');
|
| 183 |
-
await page.fill('input[type="text"]', 'That is interesting!');
|
| 184 |
-
await page.click('button[type="submit"]');
|
| 185 |
-
|
| 186 |
-
// Verify quote is shown
|
| 187 |
-
const quotedMessage = page.locator('[data-message-id]').filter({ has: page.locator('text=回覆:') }).first();
|
| 188 |
-
await expect(quotedMessage).toBeVisible({ timeout: 10000 });
|
| 189 |
-
|
| 190 |
-
console.log('✅ Quoted message sent');
|
| 191 |
-
|
| 192 |
-
// Get current URL (conversation ID)
|
| 193 |
-
const currentUrl = page.url();
|
| 194 |
-
|
| 195 |
-
// Reload page
|
| 196 |
-
await page.reload();
|
| 197 |
-
await page.waitForLoadState('networkidle');
|
| 198 |
-
await expect(page).toHaveURL(currentUrl);
|
| 199 |
-
|
| 200 |
-
console.log('✅ Page reloaded');
|
| 201 |
-
|
| 202 |
-
// Verify quoted message is still displayed
|
| 203 |
-
const quotedMessageAfterReload = page.locator('[data-message-id]').filter({ has: page.locator('text=回覆:') }).first();
|
| 204 |
-
await expect(quotedMessageAfterReload).toBeVisible({ timeout: 10000 });
|
| 205 |
-
|
| 206 |
-
// Verify the quoted text display is still there
|
| 207 |
-
const quotedTextDisplay = quotedMessageAfterReload.locator('.border-l-2');
|
| 208 |
-
await expect(quotedTextDisplay).toBeVisible();
|
| 209 |
-
|
| 210 |
-
console.log('✅✅✅ Quote persistence test passed!');
|
| 211 |
-
});
|
| 212 |
-
|
| 213 |
-
test('should handle multiple quoted replies in same conversation', async ({ page }) => {
|
| 214 |
-
test.setTimeout(120_000); // 2 minutes
|
| 215 |
-
const testUser = `quote-multi-${Date.now()}`;
|
| 216 |
-
|
| 217 |
-
// Create conversation
|
| 218 |
-
await registerAndLogin(page, testUser);
|
| 219 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 220 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 221 |
-
await page.click('text=小恩');
|
| 222 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 223 |
-
|
| 224 |
-
console.log('✅ Conversation created');
|
| 225 |
-
|
| 226 |
-
// Send first message
|
| 227 |
-
await page.fill('input[type="text"]', 'Can you help me with science?');
|
| 228 |
-
await page.click('button[type="submit"]');
|
| 229 |
-
await expect(page.locator('.whitespace-pre-wrap').first()).toBeVisible({ timeout: 60000 });
|
| 230 |
-
|
| 231 |
-
console.log('✅ First response received');
|
| 232 |
-
|
| 233 |
-
// Create first quote using triple-click (more reliable than drag)
|
| 234 |
-
const firstStudentBubble = page.locator('.bg-white.border.border-blue-100').first();
|
| 235 |
-
const textElement1 = firstStudentBubble.locator('.whitespace-pre-wrap').first();
|
| 236 |
-
await textElement1.click({ clickCount: 3 });
|
| 237 |
-
await expect(page.locator('.bg-gray-50.border-l-4.border-blue-500')).toBeVisible({ timeout: 2000 });
|
| 238 |
-
|
| 239 |
-
await page.click('button[title="詢問教練"]');
|
| 240 |
-
await page.fill('input[type="text"]', 'First quoted reply');
|
| 241 |
-
await page.click('button[type="submit"]');
|
| 242 |
-
|
| 243 |
-
console.log('✅ First quoted reply sent');
|
| 244 |
-
|
| 245 |
-
// Send another student message
|
| 246 |
-
await page.click('button[title="與學生對話"]');
|
| 247 |
-
await page.fill('input[type="text"]', 'What about math?');
|
| 248 |
-
await page.click('button[type="submit"]');
|
| 249 |
-
await expect(page.locator('.bg-white.border.border-blue-100').nth(1)).toBeVisible({ timeout: 60000 });
|
| 250 |
-
|
| 251 |
-
console.log('✅ Second response received');
|
| 252 |
-
|
| 253 |
-
// Create second quote using triple-click (more reliable than drag)
|
| 254 |
-
const secondStudentBubble = page.locator('.bg-white.border.border-blue-100').nth(1);
|
| 255 |
-
const textElement2 = secondStudentBubble.locator('.whitespace-pre-wrap').first();
|
| 256 |
-
await textElement2.click({ clickCount: 3 });
|
| 257 |
-
await expect(page.locator('.bg-gray-50.border-l-4.border-blue-500')).toBeVisible({ timeout: 2000 });
|
| 258 |
-
|
| 259 |
-
await page.click('button[title="詢問教練"]');
|
| 260 |
-
await page.fill('input[type="text"]', 'Second quoted reply');
|
| 261 |
-
await page.click('button[type="submit"]');
|
| 262 |
-
|
| 263 |
-
console.log('✅ Second quoted reply sent');
|
| 264 |
-
|
| 265 |
-
// Verify both quoted messages exist
|
| 266 |
-
const quotedMessages = page.locator('[data-message-id]').filter({ has: page.locator('text=回覆:') });
|
| 267 |
-
const count = await quotedMessages.count();
|
| 268 |
-
expect(count).toBeGreaterThanOrEqual(2);
|
| 269 |
-
|
| 270 |
-
console.log(`✅ Found ${count} quoted messages`);
|
| 271 |
-
console.log('✅✅✅ Multiple quotes test passed!');
|
| 272 |
-
});
|
| 273 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/{removed-template-handling.spec.ts → removed-template-handling.api.spec.ts}
RENAMED
|
File without changes
|
tests/e2e/{response-id-expiration.spec.ts → response-id-expiration.api.spec.ts}
RENAMED
|
File without changes
|
tests/e2e/speaker-auto-detection.spec.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
// Helper function to register and login
|
| 6 |
-
async function registerAndLogin(page: any, username: string) {
|
| 7 |
-
await page.goto(`${baseURL}/login`);
|
| 8 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 9 |
-
await page.fill('input[name="username"]', username);
|
| 10 |
-
await page.click('button:has-text("建立帳號")');
|
| 11 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
test.describe('Speaker Auto-Detection on Page Load', () => {
|
| 15 |
-
test.use({ workers: 1 }); // Run serially to avoid rate limits
|
| 16 |
-
|
| 17 |
-
test('should auto-detect student speaker after student responded last', async ({ page }) => {
|
| 18 |
-
test.setTimeout(90_000); // 90 seconds for LLM responses
|
| 19 |
-
const testUser = `speaker_detect_student_${Date.now()}`;
|
| 20 |
-
|
| 21 |
-
// Step 1: Register and login
|
| 22 |
-
await registerAndLogin(page, testUser);
|
| 23 |
-
console.log('✅ Logged in successfully');
|
| 24 |
-
|
| 25 |
-
// Step 2: Create student conversation
|
| 26 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 27 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 28 |
-
await page.click('text=睿睿');
|
| 29 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 30 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 31 |
-
|
| 32 |
-
console.log('✅ Student conversation created');
|
| 33 |
-
|
| 34 |
-
// Step 3: Send message to student
|
| 35 |
-
await page.fill('input[type="text"]', 'Hello, can you help me?');
|
| 36 |
-
await page.click('button[type="submit"]');
|
| 37 |
-
|
| 38 |
-
// Wait for student response
|
| 39 |
-
const studentResponse = page.locator('.bg-white.border.border-blue-100').first();
|
| 40 |
-
await expect(studentResponse).toBeVisible({ timeout: 60000 });
|
| 41 |
-
|
| 42 |
-
console.log('✅ Student responded');
|
| 43 |
-
|
| 44 |
-
// Step 4: Navigate away to dashboard
|
| 45 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 46 |
-
await expect(page.locator('h1:has-text("SEL Chat Coach")')).toBeVisible();
|
| 47 |
-
|
| 48 |
-
console.log('✅ Navigated to dashboard');
|
| 49 |
-
|
| 50 |
-
// Step 5: Return to conversation and check speaker auto-detection
|
| 51 |
-
await page.click('svg:has(path[d*="M4 6h16"])');
|
| 52 |
-
await expect(page.locator('h3:has-text("對話記錄")')).toBeVisible();
|
| 53 |
-
|
| 54 |
-
const conversation = page.locator('.space-y-1 > div').filter({ hasText: '睿睿' }).first();
|
| 55 |
-
await expect(conversation).toBeVisible();
|
| 56 |
-
await conversation.click();
|
| 57 |
-
|
| 58 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 59 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 60 |
-
|
| 61 |
-
console.log('✅ Returned to conversation');
|
| 62 |
-
|
| 63 |
-
// Step 6: Verify speaker was auto-detected to continue with student
|
| 64 |
-
// Since last message was from student (assistant), user should continue talking to student
|
| 65 |
-
// We can verify this by checking console logs or by trying to send a message
|
| 66 |
-
// For now, let's just verify the page loaded and we can interact
|
| 67 |
-
|
| 68 |
-
// Check console for our debug log
|
| 69 |
-
const logs: string[] = [];
|
| 70 |
-
page.on('console', msg => {
|
| 71 |
-
if (msg.text().includes('[FRONTEND]')) {
|
| 72 |
-
logs.push(msg.text());
|
| 73 |
-
}
|
| 74 |
-
});
|
| 75 |
-
|
| 76 |
-
// Trigger a reload to see the logs
|
| 77 |
-
await page.reload();
|
| 78 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 79 |
-
|
| 80 |
-
console.log('Console logs:', logs);
|
| 81 |
-
console.log('✅✅✅ Speaker auto-detection test passed!');
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
test('should auto-detect coach speaker after coach responded last', async ({ page }) => {
|
| 85 |
-
test.setTimeout(90_000);
|
| 86 |
-
const testUser = `speaker_detect_coach_${Date.now()}`;
|
| 87 |
-
|
| 88 |
-
// Step 1: Register and login
|
| 89 |
-
await registerAndLogin(page, testUser);
|
| 90 |
-
|
| 91 |
-
// Step 2: Create student conversation
|
| 92 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 93 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 94 |
-
await page.click('text=睿睿');
|
| 95 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 96 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 97 |
-
|
| 98 |
-
console.log('✅ Student conversation created');
|
| 99 |
-
|
| 100 |
-
// Step 3: Send message to student (triggers student response)
|
| 101 |
-
await page.fill('input[type="text"]', 'Hello');
|
| 102 |
-
await page.click('button[type="submit"]');
|
| 103 |
-
|
| 104 |
-
// Wait for student response
|
| 105 |
-
await expect(page.locator('.bg-white.border.border-blue-100').first()).toBeVisible({ timeout: 60000 });
|
| 106 |
-
|
| 107 |
-
console.log('✅ Student responded');
|
| 108 |
-
|
| 109 |
-
// Step 4: Manually switch to coach and send message
|
| 110 |
-
// Note: Speaker toggle is hidden, so we need to check if we can still switch programmatically
|
| 111 |
-
// Since UI is hidden, we'll skip this test for now and just verify basic auto-detection works
|
| 112 |
-
|
| 113 |
-
console.log('⚠️ Skipping coach speaker test - speaker toggle UI is hidden');
|
| 114 |
-
});
|
| 115 |
-
|
| 116 |
-
test('should not auto-detect speaker for coach_direct conversations', async ({ page }) => {
|
| 117 |
-
test.setTimeout(90_000);
|
| 118 |
-
const testUser = `speaker_detect_coach_direct_${Date.now()}`;
|
| 119 |
-
|
| 120 |
-
// Step 1: Register and login
|
| 121 |
-
await registerAndLogin(page, testUser);
|
| 122 |
-
|
| 123 |
-
// Step 2: Create coach-direct conversation
|
| 124 |
-
await page.click('h3:has-text("教練回顧")');
|
| 125 |
-
await expect(page.locator('text=開始諮詢')).toBeVisible({ timeout: 5_000 });
|
| 126 |
-
await page.click('button:has-text("開始諮詢")');
|
| 127 |
-
|
| 128 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 129 |
-
await expect(page.locator('h1')).toBeVisible({ timeout: 10_000 });
|
| 130 |
-
|
| 131 |
-
console.log('✅ Coach-direct conversation created');
|
| 132 |
-
|
| 133 |
-
// Step 3: Send message
|
| 134 |
-
await page.fill('input[type="text"]', 'I need coaching advice');
|
| 135 |
-
await page.click('button[type="submit"]');
|
| 136 |
-
|
| 137 |
-
// Wait for coach response
|
| 138 |
-
const coachResponse = page.locator('.bg-gradient-to-br.from-purple-50').first();
|
| 139 |
-
await expect(coachResponse).toBeVisible({ timeout: 60000 });
|
| 140 |
-
|
| 141 |
-
console.log('✅ Coach responded');
|
| 142 |
-
|
| 143 |
-
// Step 4: Reload page
|
| 144 |
-
await page.reload();
|
| 145 |
-
await expect(page.locator('input#chat-input')).toBeVisible();
|
| 146 |
-
|
| 147 |
-
// Verify no speaker auto-detection happened (coach_direct doesn't need it)
|
| 148 |
-
// This is indicated by studentPromptId === 'coach_direct'
|
| 149 |
-
console.log('✅✅✅ Coach-direct conversation correctly skipped auto-detection!');
|
| 150 |
-
});
|
| 151 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/speaker-toggle-bug.spec.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
import { execSync } from 'child_process';
|
| 3 |
-
|
| 4 |
-
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
| 5 |
-
|
| 6 |
-
// Helper function to register and login via UI
|
| 7 |
-
async function registerAndLogin(page: any, username: string) {
|
| 8 |
-
await page.goto(`${API_URL}/login`);
|
| 9 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 10 |
-
await page.fill('input[name="username"]', username);
|
| 11 |
-
await page.click('button:has-text("建立帳號")');
|
| 12 |
-
await expect(page).toHaveURL(`${API_URL}/dashboard`, { timeout: 10_000 });
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
// SKIPPED: Speaker toggle buttons are hidden in UI
|
| 16 |
-
test.describe.skip('Speaker Toggle Bug After Coach Ad', () => {
|
| 17 |
-
test('should allow switching back to student after accepting coach ad prompt', async ({ page }) => {
|
| 18 |
-
test.setTimeout(120_000); // 2 minutes for LLM responses
|
| 19 |
-
const testUser = `speaker-toggle-bug-${Date.now()}`;
|
| 20 |
-
|
| 21 |
-
console.log('🧪 Starting speaker toggle bug reproduction test...');
|
| 22 |
-
|
| 23 |
-
// Step 1: Register and login via UI
|
| 24 |
-
console.log('📝 Step 1: Registering and logging in...');
|
| 25 |
-
await registerAndLogin(page, testUser);
|
| 26 |
-
console.log('✅ Logged in successfully');
|
| 27 |
-
|
| 28 |
-
// Step 2: Create conversation via UI
|
| 29 |
-
console.log('📝 Step 2: Creating student conversation...');
|
| 30 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 31 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 32 |
-
await page.click('text=睿睿');
|
| 33 |
-
await expect(page).toHaveURL(/\/conversation\//, { timeout: 10_000 });
|
| 34 |
-
await page.waitForTimeout(1000);
|
| 35 |
-
|
| 36 |
-
// Get conversation ID from URL
|
| 37 |
-
const convId = page.url().match(/\/conversation\/([^/?]+)/)?.[1];
|
| 38 |
-
expect(convId).toBeTruthy();
|
| 39 |
-
console.log(`✅ Created conversation: ${convId}`);
|
| 40 |
-
|
| 41 |
-
// Step 3: Verify initial state - student button should be active
|
| 42 |
-
console.log('📝 Step 3: Verifying initial speaker state...');
|
| 43 |
-
const studentButton = page.locator('button[title="與學生對話"]');
|
| 44 |
-
const coachButton = page.locator('button[title="詢問教練"]');
|
| 45 |
-
|
| 46 |
-
await expect(studentButton).toBeVisible();
|
| 47 |
-
await expect(coachButton).toBeVisible();
|
| 48 |
-
await expect(studentButton).toHaveClass(/from-blue-400/);
|
| 49 |
-
console.log('✅ Initial state: student button active');
|
| 50 |
-
|
| 51 |
-
// Step 4: Set message count to 14 via SQLite (so next message will be #15)
|
| 52 |
-
console.log('📝 Step 4: Setting message count to 14 via SQLite...');
|
| 53 |
-
execSync(
|
| 54 |
-
`sqlite3 ./data/app.db "UPDATE conversations SET user_to_student_message_count = 14 WHERE id = '${convId}';"`,
|
| 55 |
-
{ encoding: 'utf-8' }
|
| 56 |
-
);
|
| 57 |
-
console.log('✅ Message count set to 14');
|
| 58 |
-
|
| 59 |
-
// Step 5: Send 15th message (triggers coach ad prompt)
|
| 60 |
-
console.log('📝 Step 5: Sending 15th message to trigger coach ad...');
|
| 61 |
-
await page.fill('input[type="text"]', 'Help me with my homework please');
|
| 62 |
-
await page.click('button[type="submit"]');
|
| 63 |
-
|
| 64 |
-
// Wait for student response to complete (indicates streaming is done)
|
| 65 |
-
const studentResponse = page.locator('.bg-white.border.border-blue-100').first();
|
| 66 |
-
await expect(studentResponse).toBeVisible({ timeout: 60_000 });
|
| 67 |
-
console.log('✅ Student response received');
|
| 68 |
-
|
| 69 |
-
// Wait for modal to appear
|
| 70 |
-
await page.waitForTimeout(2000);
|
| 71 |
-
|
| 72 |
-
// Step 6: Verify coach ad modal appears
|
| 73 |
-
console.log('📝 Step 6: Verifying coach ad modal appeared...');
|
| 74 |
-
await expect(page.locator('text=諮詢教練建議')).toBeVisible({ timeout: 10_000 });
|
| 75 |
-
await expect(page.locator('text=需要向教練尋求建議嗎')).toBeVisible();
|
| 76 |
-
console.log('✅ Coach ad modal appeared');
|
| 77 |
-
|
| 78 |
-
// Step 7: Accept the coach ad prompt
|
| 79 |
-
console.log('📝 Step 7: Accepting coach ad prompt...');
|
| 80 |
-
const okButton = page.locator('button:has-text("好的,諮詢教練")');
|
| 81 |
-
await expect(okButton).toBeVisible();
|
| 82 |
-
await okButton.click();
|
| 83 |
-
|
| 84 |
-
// Wait for coach message generation and response
|
| 85 |
-
await page.waitForTimeout(5000);
|
| 86 |
-
|
| 87 |
-
// Wait for coach response (purple gradient background)
|
| 88 |
-
const coachMessage = page.locator('.bg-gradient-to-br.from-purple-50');
|
| 89 |
-
await expect(coachMessage.first()).toBeVisible({ timeout: 60_000 });
|
| 90 |
-
console.log('✅ Coach message received');
|
| 91 |
-
|
| 92 |
-
// Step 8: Verify speaker automatically switched to coach
|
| 93 |
-
console.log('📝 Step 8: Verifying speaker switched to coach...');
|
| 94 |
-
await page.waitForTimeout(1000);
|
| 95 |
-
await expect(coachButton).toHaveClass(/from-purple-400/);
|
| 96 |
-
await expect(studentButton).toHaveClass(/bg-gray-200/);
|
| 97 |
-
console.log('✅ Speaker automatically switched to coach mode');
|
| 98 |
-
|
| 99 |
-
// Step 9: THE BUG TEST - Try to manually click student button to switch back
|
| 100 |
-
console.log('📝 Step 9: Testing manual switch back to student (BUG REPRODUCTION)...');
|
| 101 |
-
await studentButton.click();
|
| 102 |
-
await page.waitForTimeout(1000);
|
| 103 |
-
|
| 104 |
-
// Step 10: Verify student button is now active (THIS SHOULD PASS AFTER FIX)
|
| 105 |
-
console.log('📝 Step 10: Verifying student button is active...');
|
| 106 |
-
await expect(studentButton).toHaveClass(/from-blue-400/, {
|
| 107 |
-
timeout: 5_000
|
| 108 |
-
});
|
| 109 |
-
await expect(coachButton).toHaveClass(/bg-gray-200/);
|
| 110 |
-
console.log('✅ Successfully switched back to student mode!');
|
| 111 |
-
|
| 112 |
-
// Step 11: Send a message as student to verify it works
|
| 113 |
-
console.log('📝 Step 11: Sending message as student to verify...');
|
| 114 |
-
await page.fill('input[type="text"]', 'Testing student mode');
|
| 115 |
-
await page.click('button[type="submit"]');
|
| 116 |
-
|
| 117 |
-
// Wait for student response
|
| 118 |
-
await page.waitForTimeout(3000);
|
| 119 |
-
const finalStudentResponse = page.locator('.bg-white.border.border-blue-100').last();
|
| 120 |
-
await expect(finalStudentResponse).toBeVisible({ timeout: 60_000 });
|
| 121 |
-
console.log('✅ Student message sent and response received!');
|
| 122 |
-
|
| 123 |
-
console.log('');
|
| 124 |
-
console.log('🎉🎉🎉 SPEAKER TOGGLE BUG TEST PASSED! 🎉🎉🎉');
|
| 125 |
-
});
|
| 126 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/{title-generation-logic.spec.ts → title-generation-logic.api.spec.ts}
RENAMED
|
File without changes
|
tests/e2e/ui-auth-redirect.spec.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('UI Authentication & Auto-Redirect Tests', () => {
|
| 6 |
-
test.describe('Auto-redirect to /login on 401', () => {
|
| 7 |
-
test('Should redirect to /login when accessing dashboard without auth', async ({ page }) => {
|
| 8 |
-
test.setTimeout(30_000);
|
| 9 |
-
|
| 10 |
-
// 1. Go directly to dashboard without logging in
|
| 11 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 12 |
-
|
| 13 |
-
// 2. Should be redirected to /login via useEffect
|
| 14 |
-
await expect(page).toHaveURL(`${baseURL}/login`, { timeout: 10_000 });
|
| 15 |
-
|
| 16 |
-
// Verify login form is shown
|
| 17 |
-
await expect(page.locator('h1:has-text("SEL 對話教練")')).toBeVisible();
|
| 18 |
-
await expect(page.locator('input[name="username"]')).toBeVisible();
|
| 19 |
-
});
|
| 20 |
-
|
| 21 |
-
test('Should redirect to /login when accessing conversation page without auth', async ({ page }) => {
|
| 22 |
-
test.setTimeout(30_000);
|
| 23 |
-
|
| 24 |
-
// 1. Try to access a conversation page directly
|
| 25 |
-
const fakeConvId = '00000000-0000-0000-0000-000000000000';
|
| 26 |
-
await page.goto(`${baseURL}/conversation/${fakeConvId}`);
|
| 27 |
-
|
| 28 |
-
// 2. Should be redirected to /login via useEffect
|
| 29 |
-
await expect(page).toHaveURL(`${baseURL}/login`, { timeout: 10_000 });
|
| 30 |
-
|
| 31 |
-
// Verify login form is shown
|
| 32 |
-
await expect(page.locator('h1:has-text("SEL 對話教練")')).toBeVisible();
|
| 33 |
-
await expect(page.locator('input[name="username"]')).toBeVisible();
|
| 34 |
-
});
|
| 35 |
-
});
|
| 36 |
-
|
| 37 |
-
test.describe('Auth state persistence', () => {
|
| 38 |
-
test('Auth state persists across page reloads', async ({ page }) => {
|
| 39 |
-
test.setTimeout(60_000);
|
| 40 |
-
const testUser = `persist_test_${Date.now()}`;
|
| 41 |
-
|
| 42 |
-
// 1. Register and login
|
| 43 |
-
await page.goto(`${baseURL}/login`);
|
| 44 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 45 |
-
await page.fill('input[name="username"]', testUser);
|
| 46 |
-
await page.click('button:has-text("建立帳號")');
|
| 47 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 48 |
-
|
| 49 |
-
// 2. Verify login state
|
| 50 |
-
await expect(page.locator(`text=你好,${testUser}!`)).toBeVisible();
|
| 51 |
-
|
| 52 |
-
// 3. Reload the page
|
| 53 |
-
await page.reload();
|
| 54 |
-
|
| 55 |
-
// 4. Should still be logged in and on dashboard
|
| 56 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 57 |
-
await expect(page.locator(`text=你好,${testUser}!`)).toBeVisible();
|
| 58 |
-
|
| 59 |
-
// 5. Navigate to home page
|
| 60 |
-
await page.goto(`${baseURL}/`);
|
| 61 |
-
|
| 62 |
-
// 6. Should redirect to dashboard (since user is logged in)
|
| 63 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 64 |
-
});
|
| 65 |
-
|
| 66 |
-
test('Logout clears auth state and redirects to login', async ({ page }) => {
|
| 67 |
-
test.setTimeout(60_000);
|
| 68 |
-
const testUser = `logout_test_${Date.now()}`;
|
| 69 |
-
|
| 70 |
-
// 1. Register and login
|
| 71 |
-
await page.goto(`${baseURL}/login`);
|
| 72 |
-
await page.click('text=沒有帳號?建立新帳號');
|
| 73 |
-
await page.fill('input[name="username"]', testUser);
|
| 74 |
-
await page.click('button:has-text("建立帳號")');
|
| 75 |
-
await expect(page).toHaveURL(`${baseURL}/dashboard`, { timeout: 10_000 });
|
| 76 |
-
|
| 77 |
-
// 2. Open sidebar
|
| 78 |
-
const menuButton = page.locator('button').filter({ has: page.locator('svg path[d*="M4 6h16"]') });
|
| 79 |
-
await menuButton.click();
|
| 80 |
-
|
| 81 |
-
// 3. Wait for sidebar to appear
|
| 82 |
-
await expect(page.locator('h2:has-text("選單")')).toBeVisible();
|
| 83 |
-
|
| 84 |
-
// 4. Click logout
|
| 85 |
-
await page.click('button:has-text("登出")');
|
| 86 |
-
|
| 87 |
-
// 5. Should be redirected to /login
|
| 88 |
-
await expect(page).toHaveURL(`${baseURL}/login`, { timeout: 10_000 });
|
| 89 |
-
|
| 90 |
-
// 6. Verify auth credentials are cleared
|
| 91 |
-
const hasCredentials = await page.evaluate(() => {
|
| 92 |
-
return localStorage.getItem('auth_credentials') !== null;
|
| 93 |
-
});
|
| 94 |
-
expect(hasCredentials).toBeFalsy();
|
| 95 |
-
|
| 96 |
-
// 7. Trying to access dashboard should redirect back to login
|
| 97 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 98 |
-
await expect(page).toHaveURL(`${baseURL}/login`, { timeout: 10_000 });
|
| 99 |
-
});
|
| 100 |
-
});
|
| 101 |
-
|
| 102 |
-
test.describe('useAuthFetch hook behavior', () => {
|
| 103 |
-
test('useAuthFetch automatically adds auth headers', async ({ page, request }) => {
|
| 104 |
-
test.setTimeout(90_000);
|
| 105 |
-
const testUser = `fetch_test_${Date.now()}`;
|
| 106 |
-
|
| 107 |
-
// 1. Register via API
|
| 108 |
-
const PW = 'cz-2025';
|
| 109 |
-
await request.post(`${baseURL}/api/auth/register`, {
|
| 110 |
-
data: { username: testUser },
|
| 111 |
-
});
|
| 112 |
-
|
| 113 |
-
// 2. Login via UI
|
| 114 |
-
await page.goto(`${baseURL}/login`);
|
| 115 |
-
await page.fill('input[name="username"]', testUser);
|
| 116 |
-
await page.fill('input[name="password"]', PW);
|
| 117 |
-
await page.click('button:has-text("登入")');
|
| 118 |
-
await expect(page).toHaveURL(/\/(dashboard)?$/, { timeout: 10_000 });
|
| 119 |
-
|
| 120 |
-
// Navigate to dashboard if needed
|
| 121 |
-
if (!(await page.url().includes('dashboard'))) {
|
| 122 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
// 3. Trigger a fetch operation (create conversation)
|
| 126 |
-
await page.click('h3:has-text("指定對話演練")');
|
| 127 |
-
await expect(page.locator('h3:has-text("選擇學生")')).toBeVisible();
|
| 128 |
-
|
| 129 |
-
// Click on the clickable student card (not just any div with emoji)
|
| 130 |
-
// Use a selector that targets the outer card with cursor-pointer class
|
| 131 |
-
const firstStudentCard = page.locator('.cursor-pointer').filter({ hasText: '🎓' }).first();
|
| 132 |
-
await firstStudentCard.click();
|
| 133 |
-
|
| 134 |
-
// 4. Should succeed and navigate to conversation
|
| 135 |
-
await expect(page).toHaveURL(/\/conversation\/[a-f0-9-]+/, { timeout: 15_000 });
|
| 136 |
-
|
| 137 |
-
// 5. Verify conversation loaded successfully (auth header was added)
|
| 138 |
-
// The placeholder contains "對話" (conversation) not "輸入" (input)
|
| 139 |
-
await expect(page.locator('input[placeholder*="對話"]')).toBeVisible({ timeout: 10_000 });
|
| 140 |
-
});
|
| 141 |
-
});
|
| 142 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/e2e/verify-no-nested-buttons.spec.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
import { test, expect } from '@playwright/test';
|
| 2 |
-
|
| 3 |
-
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
| 4 |
-
|
| 5 |
-
test.describe('Hydration Error Fix Verification', () => {
|
| 6 |
-
test('should verify no nested buttons exist in entire app', async ({ page }) => {
|
| 7 |
-
test.setTimeout(60_000);
|
| 8 |
-
|
| 9 |
-
// Just visit the login page and check for nested buttons
|
| 10 |
-
await page.goto(`${baseURL}/login`);
|
| 11 |
-
|
| 12 |
-
// Check entire page for any nested buttons
|
| 13 |
-
const nestedButtonsCount = await page.locator('button button').count();
|
| 14 |
-
|
| 15 |
-
console.log(`Found ${nestedButtonsCount} nested button(s) on login page`);
|
| 16 |
-
expect(nestedButtonsCount).toBe(0);
|
| 17 |
-
|
| 18 |
-
// Visit dashboard (will redirect to login but that's fine for structure check)
|
| 19 |
-
await page.goto(`${baseURL}/dashboard`);
|
| 20 |
-
await page.waitForLoadState('domcontentloaded');
|
| 21 |
-
|
| 22 |
-
const nestedOnDashboard = await page.locator('button button').count();
|
| 23 |
-
console.log(`Found ${nestedOnDashboard} nested button(s) on dashboard page`);
|
| 24 |
-
expect(nestedOnDashboard).toBe(0);
|
| 25 |
-
|
| 26 |
-
console.log('✅ No nested buttons found in app - hydration error fixed!');
|
| 27 |
-
});
|
| 28 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|