Compare commits
10 Commits
6f64b1daca
...
13e150d7d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e150d7d7 | ||
|
|
482d36fc8a | ||
|
|
fc5a46fa94 | ||
|
|
fa4b936421 | ||
|
|
43334afaf6 | ||
|
|
ebf7162ae5 | ||
|
|
b56456cbd9 | ||
|
|
319e3221a3 | ||
| 3a71ac9cc4 | |||
| 95684a34f7 |
@@ -97,7 +97,15 @@ Execution steps:
|
|||||||
|
|
||||||
4. Sequential questioning loop (interactive):
|
4. Sequential questioning loop (interactive):
|
||||||
- Present EXACTLY ONE question at a time.
|
- Present EXACTLY ONE question at a time.
|
||||||
- For multiple‑choice questions render options as a Markdown table:
|
- For multiple‑choice questions:
|
||||||
|
* **Analyze all options** and determine the **most suitable option** based on:
|
||||||
|
- Best practices for the project type
|
||||||
|
- Common patterns in similar implementations
|
||||||
|
- Risk reduction (security, performance, maintainability)
|
||||||
|
- Alignment with any explicit project goals or constraints visible in the spec
|
||||||
|
* Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||||
|
* Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||||
|
* Then render all options as a Markdown table:
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
@@ -106,9 +114,14 @@ Execution steps:
|
|||||||
| C | <Option C description> | (add D/E as needed up to 5)
|
| C | <Option C description> | (add D/E as needed up to 5)
|
||||||
| Short | Provide a different short answer (<=5 words) | (Include only if free-form alternative is appropriate)
|
| Short | Provide a different short answer (<=5 words) | (Include only if free-form alternative is appropriate)
|
||||||
|
|
||||||
- For short‑answer style (no meaningful discrete options), output a single line after the question: `Format: Short answer (<=5 words)`.
|
* After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||||
|
- For short‑answer style (no meaningful discrete options):
|
||||||
|
* Provide your **suggested answer** based on best practices and context.
|
||||||
|
* Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||||
|
* Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||||
- After the user answers:
|
- After the user answers:
|
||||||
* Validate the answer maps to one option or fits the <=5 word constraint.
|
* If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||||
|
* Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||||
* If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
* If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||||
* Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
* Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||||
- Stop asking further questions when:
|
- Stop asking further questions when:
|
||||||
|
|||||||
@@ -54,27 +54,61 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||||
|
|
||||||
4. Parse tasks.md structure and extract:
|
4. **Project Setup Verification**:
|
||||||
|
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||||
|
|
||||||
|
**Detection & Creation Logic**:
|
||||||
|
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git rev-parse --git-dir 2>/dev/null
|
||||||
|
```
|
||||||
|
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||||
|
- Check if .eslintrc* or eslint.config.* exists → create/verify .eslintignore
|
||||||
|
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||||
|
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||||
|
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||||
|
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||||
|
|
||||||
|
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||||
|
**If ignore file missing**: Create with full pattern set for detected technology
|
||||||
|
|
||||||
|
**Common Patterns by Technology** (from plan.md tech stack):
|
||||||
|
- **Node.js/JavaScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||||
|
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||||
|
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||||
|
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||||
|
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||||
|
- **Ruby on Rails**: `Gemfile.lock`, `config/database.yml`, `db/*.sqlite3`, `log/*.log`, `tmp/`, `vendor/bundle/`
|
||||||
|
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||||
|
|
||||||
|
**Tool-Specific Patterns**:
|
||||||
|
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||||
|
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||||
|
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||||
|
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||||
|
|
||||||
|
5. Parse tasks.md structure and extract:
|
||||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||||
- **Task dependencies**: Sequential vs parallel execution rules
|
- **Task dependencies**: Sequential vs parallel execution rules
|
||||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||||
- **Execution flow**: Order and dependency requirements
|
- **Execution flow**: Order and dependency requirements
|
||||||
|
|
||||||
5. Execute implementation following the task plan:
|
6. Execute implementation following the task plan:
|
||||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||||
|
|
||||||
6. Implementation execution rules:
|
7. Implementation execution rules:
|
||||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||||
- **Integration work**: Database connections, middleware, logging, external services
|
- **Integration work**: Database connections, middleware, logging, external services
|
||||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||||
|
|
||||||
7. Progress tracking and error handling:
|
8. Progress tracking and error handling:
|
||||||
- Report progress after each completed task
|
- Report progress after each completed task
|
||||||
- Halt execution if any non-parallel task fails
|
- Halt execution if any non-parallel task fails
|
||||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||||
@@ -82,7 +116,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Suggest next steps if implementation cannot proceed
|
- Suggest next steps if implementation cannot proceed
|
||||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||||
|
|
||||||
8. Completion validation:
|
9. Completion validation:
|
||||||
- Verify all required tasks are completed
|
- Verify all required tasks are completed
|
||||||
- Check that implemented features match the original specification
|
- Check that implemented features match the original specification
|
||||||
- Validate that tests pass and coverage meets requirements
|
- Validate that tests pass and coverage meets requirements
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
|
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
2. **Load context**: Read FEATURE_SPEC and `.specify.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||||
|
|
||||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||||
|
|||||||
@@ -22,27 +22,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
|
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
|
||||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||||
|
|
||||||
3. **Execute task generation workflow** (follow the template structure):
|
3. **Execute task generation workflow**:
|
||||||
- Load plan.md and extract tech stack, libraries, project structure
|
- Load plan.md and extract tech stack, libraries, project structure
|
||||||
- **Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)**
|
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||||
- If data-model.md exists: Extract entities → map to user stories
|
- If data-model.md exists: Extract entities and map to user stories
|
||||||
- If contracts/ exists: Each file → map endpoints to user stories
|
- If contracts/ exists: Map endpoints to user stories
|
||||||
- If research.md exists: Extract decisions → generate setup tasks
|
- If research.md exists: Extract decisions for setup tasks
|
||||||
- **Generate tasks ORGANIZED BY USER STORY**:
|
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||||
- Setup tasks (shared infrastructure needed by all stories)
|
|
||||||
- **Foundational tasks (prerequisites that must complete before ANY user story can start)**
|
|
||||||
- For each user story (in priority order P1, P2, P3...):
|
|
||||||
- Group all tasks needed to complete JUST that story
|
|
||||||
- Include models, services, endpoints, UI components specific to that story
|
|
||||||
- Mark which tasks are [P] parallelizable
|
|
||||||
- If tests requested: Include tests specific to that story
|
|
||||||
- Polish/Integration tasks (cross-cutting concerns)
|
|
||||||
- **Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature spec or user asks for TDD approach
|
|
||||||
- Apply task rules:
|
|
||||||
- Different files = mark [P] for parallel
|
|
||||||
- Same file = sequential (no [P])
|
|
||||||
- If tests requested: Tests before implementation (TDD order)
|
|
||||||
- Number tasks sequentially (T001, T002...)
|
|
||||||
- Generate dependency graph showing user story completion order
|
- Generate dependency graph showing user story completion order
|
||||||
- Create parallel execution examples per user story
|
- Create parallel execution examples per user story
|
||||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||||
@@ -53,11 +39,8 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||||
- Clear [Story] labels (US1, US2, US3...) for each task
|
|
||||||
- [P] markers for parallelizable tasks within each story
|
|
||||||
- Checkpoint markers after each story phase
|
|
||||||
- Final Phase: Polish & cross-cutting concerns
|
- Final Phase: Polish & cross-cutting concerns
|
||||||
- Numbered tasks (T001, T002...) in execution order
|
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||||
- Clear file paths for each task
|
- Clear file paths for each task
|
||||||
- Dependencies section showing story completion order
|
- Dependencies section showing story completion order
|
||||||
- Parallel execution examples per story
|
- Parallel execution examples per story
|
||||||
@@ -69,6 +52,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Parallel opportunities identified
|
- Parallel opportunities identified
|
||||||
- Independent test criteria for each story
|
- Independent test criteria for each story
|
||||||
- Suggested MVP scope (typically just User Story 1)
|
- Suggested MVP scope (typically just User Story 1)
|
||||||
|
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||||
|
|
||||||
Context for task generation: {{args}}
|
Context for task generation: {{args}}
|
||||||
|
|
||||||
@@ -76,10 +60,44 @@ The tasks.md should be immediately executable - each task must be specific enoug
|
|||||||
|
|
||||||
## Task Generation Rules
|
## Task Generation Rules
|
||||||
|
|
||||||
**IMPORTANT**: Tests are optional. Only generate test tasks if the user explicitly requested testing or TDD approach in the feature specification.
|
|
||||||
|
|
||||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||||
|
|
||||||
|
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||||
|
|
||||||
|
### Checklist Format (REQUIRED)
|
||||||
|
|
||||||
|
Every task MUST strictly follow this format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format Components**:
|
||||||
|
|
||||||
|
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||||
|
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||||
|
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||||
|
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||||
|
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||||
|
- Setup phase: NO story label
|
||||||
|
- Foundational phase: NO story label
|
||||||
|
- User Story phases: MUST have story label
|
||||||
|
- Polish phase: NO story label
|
||||||
|
5. **Description**: Clear action with exact file path
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||||
|
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||||
|
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||||
|
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||||
|
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||||
|
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||||
|
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||||
|
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||||
|
|
||||||
|
### Task Organization
|
||||||
|
|
||||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||||
- Each user story (P1, P2, P3...) gets its own phase
|
- Each user story (P1, P2, P3...) gets its own phase
|
||||||
- Map all related components to their story:
|
- Map all related components to their story:
|
||||||
@@ -94,22 +112,21 @@ The tasks.md should be immediately executable - each task must be specific enoug
|
|||||||
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
||||||
|
|
||||||
3. **From Data Model**:
|
3. **From Data Model**:
|
||||||
- Map each entity → to the user story(ies) that need it
|
- Map each entity to the user story(ies) that need it
|
||||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||||
- Relationships → service layer tasks in appropriate story phase
|
- Relationships → service layer tasks in appropriate story phase
|
||||||
|
|
||||||
4. **From Setup/Infrastructure**:
|
4. **From Setup/Infrastructure**:
|
||||||
- Shared infrastructure → Setup phase (Phase 1)
|
- Shared infrastructure → Setup phase (Phase 1)
|
||||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||||
- Examples: Database schema setup, authentication framework, core libraries, base configurations
|
|
||||||
- These MUST complete before any user story can be implemented
|
|
||||||
- Story-specific setup → within that story's phase
|
- Story-specific setup → within that story's phase
|
||||||
|
|
||||||
5. **Ordering**:
|
### Phase Structure
|
||||||
- Phase 1: Setup (project initialization)
|
|
||||||
- Phase 2: Foundational (blocking prerequisites - must complete before user stories)
|
- **Phase 1**: Setup (project initialization)
|
||||||
- Phase 3+: User Stories in priority order (P1, P2, P3...)
|
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||||
|
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||||
- Final Phase: Polish & Cross-Cutting Concerns
|
- Each phase should be a complete, independently testable increment
|
||||||
- Each user story phase should be a complete, independently testable increment
|
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||||
"""
|
"""
|
||||||
|
|||||||
10
.gemini/settings.json
Normal file
10
.gemini/settings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
.github/chatmodes/ 🎭 planner.chatmode.md
vendored
Normal file
92
.github/chatmodes/ 🎭 planner.chatmode.md
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
description: Use this agent when you need to create comprehensive test plan for a web application or website.
|
||||||
|
tools: ['edit/createFile', 'edit/createDirectory', 'search/fileSearch', 'search/textSearch', 'search/listDirectory', 'search/readFile', 'playwright-test/browser_click', 'playwright-test/browser_close', 'playwright-test/browser_console_messages', 'playwright-test/browser_drag', 'playwright-test/browser_evaluate', 'playwright-test/browser_file_upload', 'playwright-test/browser_handle_dialog', 'playwright-test/browser_hover', 'playwright-test/browser_navigate', 'playwright-test/browser_navigate_back', 'playwright-test/browser_network_requests', 'playwright-test/browser_press_key', 'playwright-test/browser_select_option', 'playwright-test/browser_snapshot', 'playwright-test/browser_take_screenshot', 'playwright-test/browser_type', 'playwright-test/browser_wait_for', 'playwright-test/planner_setup_page']
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
|
||||||
|
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
|
||||||
|
planning.
|
||||||
|
|
||||||
|
You will:
|
||||||
|
|
||||||
|
1. **Navigate and Explore**
|
||||||
|
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
|
||||||
|
- Explore the browser snapshot
|
||||||
|
- Do not take screenshots unless absolutely necessary
|
||||||
|
- Use browser_* tools to navigate and discover interface
|
||||||
|
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
|
||||||
|
|
||||||
|
2. **Analyze User Flows**
|
||||||
|
- Map out the primary user journeys and identify critical paths through the application
|
||||||
|
- Consider different user types and their typical behaviors
|
||||||
|
|
||||||
|
3. **Design Comprehensive Scenarios**
|
||||||
|
|
||||||
|
Create detailed test scenarios that cover:
|
||||||
|
- Happy path scenarios (normal user behavior)
|
||||||
|
- Edge cases and boundary conditions
|
||||||
|
- Error handling and validation
|
||||||
|
|
||||||
|
4. **Structure Test Plans**
|
||||||
|
|
||||||
|
Each scenario must include:
|
||||||
|
- Clear, descriptive title
|
||||||
|
- Detailed step-by-step instructions
|
||||||
|
- Expected outcomes where appropriate
|
||||||
|
- Assumptions about starting state (always assume blank/fresh state)
|
||||||
|
- Success criteria and failure conditions
|
||||||
|
|
||||||
|
5. **Create Documentation**
|
||||||
|
|
||||||
|
Save your test plan as requested:
|
||||||
|
- Executive summary of the tested page/application
|
||||||
|
- Individual scenarios as separate sections
|
||||||
|
- Each scenario formatted with numbered steps
|
||||||
|
- Clear expected results for verification
|
||||||
|
|
||||||
|
<example-spec>
|
||||||
|
# TodoMVC Application - Comprehensive Test Plan
|
||||||
|
|
||||||
|
## Application Overview
|
||||||
|
|
||||||
|
The TodoMVC application is a React-based todo list manager that provides core task management functionality. The
|
||||||
|
application features:
|
||||||
|
|
||||||
|
- **Task Management**: Add, edit, complete, and delete individual todos
|
||||||
|
- **Bulk Operations**: Mark all todos as complete/incomplete and clear all completed todos
|
||||||
|
- **Filtering**: View todos by All, Active, or Completed status
|
||||||
|
- **URL Routing**: Support for direct navigation to filtered views via URLs
|
||||||
|
- **Counter Display**: Real-time count of active (incomplete) todos
|
||||||
|
- **Persistence**: State maintained during session (browser refresh behavior not tested)
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### 1. Adding New Todos
|
||||||
|
|
||||||
|
**Seed:** `tests/seed.spec.ts`
|
||||||
|
|
||||||
|
#### 1.1 Add Valid Todo
|
||||||
|
**Steps:**
|
||||||
|
1. Click in the "What needs to be done?" input field
|
||||||
|
2. Type "Buy groceries"
|
||||||
|
3. Press Enter key
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- Todo appears in the list with unchecked checkbox
|
||||||
|
- Counter shows "1 item left"
|
||||||
|
- Input field is cleared and ready for next entry
|
||||||
|
- Todo list controls become visible (Mark all as complete checkbox)
|
||||||
|
|
||||||
|
#### 1.2
|
||||||
|
...
|
||||||
|
</example-spec>
|
||||||
|
|
||||||
|
**Quality Standards**:
|
||||||
|
- Write steps that are specific enough for any tester to follow
|
||||||
|
- Include negative testing scenarios
|
||||||
|
- Ensure scenarios are independent and can be run in any order
|
||||||
|
|
||||||
|
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
|
||||||
|
professional formatting suitable for sharing with development and QA teams.
|
||||||
|
<example>Context: User wants to test a new e-commerce checkout flow. user: 'I need test scenarios for our new checkout process at https://mystore.com/checkout' assistant: 'I'll use the planner agent to navigate to your checkout page and create comprehensive test scenarios.' <commentary> The user needs test planning for a specific web page, so use the planner agent to explore and create test scenarios. </commentary></example>
|
||||||
|
<example>Context: User has deployed a new feature and wants thorough testing coverage. user: 'Can you help me test our new user dashboard at https://app.example.com/dashboard?' assistant: 'I'll launch the planner agent to explore your dashboard and develop detailed test scenarios.' <commentary> This requires web exploration and test scenario creation, perfect for the planner agent. </commentary></example>
|
||||||
58
.github/chatmodes/🎭 generator.chatmode.md
vendored
Normal file
58
.github/chatmodes/🎭 generator.chatmode.md
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
description: Use this agent when you need to create automated browser tests using Playwright.
|
||||||
|
tools: ['search/fileSearch', 'search/textSearch', 'search/listDirectory', 'search/readFile', 'playwright-test/browser_click', 'playwright-test/browser_drag', 'playwright-test/browser_evaluate', 'playwright-test/browser_file_upload', 'playwright-test/browser_handle_dialog', 'playwright-test/browser_hover', 'playwright-test/browser_navigate', 'playwright-test/browser_press_key', 'playwright-test/browser_select_option', 'playwright-test/browser_snapshot', 'playwright-test/browser_type', 'playwright-test/browser_verify_element_visible', 'playwright-test/browser_verify_list_visible', 'playwright-test/browser_verify_text_visible', 'playwright-test/browser_verify_value', 'playwright-test/browser_wait_for', 'playwright-test/generator_read_log', 'playwright-test/generator_setup_page', 'playwright-test/generator_write_test']
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
|
||||||
|
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
|
||||||
|
application behavior.
|
||||||
|
|
||||||
|
# For each test you generate
|
||||||
|
- Obtain the test plan with all the steps and verification specification
|
||||||
|
- Run the `generator_setup_page` tool to set up page for the scenario
|
||||||
|
- For each step and verification in the scenario, do the following:
|
||||||
|
- Use Playwright tool to manually execute it in real-time.
|
||||||
|
- Use the step description as the intent for each Playwright tool call.
|
||||||
|
- Retrieve generator log via `generator_read_log`
|
||||||
|
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
|
||||||
|
- File should contain single test
|
||||||
|
- File name must be fs-friendly scenario name
|
||||||
|
- Test must be placed in a describe matching the top-level test plan item
|
||||||
|
- Test title must match the scenario name
|
||||||
|
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
|
||||||
|
multiple actions.
|
||||||
|
- Always use best practices from the log when generating tests.
|
||||||
|
|
||||||
|
<example-generation>
|
||||||
|
For following plan:
|
||||||
|
|
||||||
|
```markdown file=specs/plan.md
|
||||||
|
### 1. Adding New Todos
|
||||||
|
**Seed:** `tests/seed.spec.ts`
|
||||||
|
|
||||||
|
#### 1.1 Add Valid Todo
|
||||||
|
**Steps:**
|
||||||
|
1. Click in the "What needs to be done?" input field
|
||||||
|
|
||||||
|
#### 1.2 Add Multiple Todos
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Following file is generated:
|
||||||
|
|
||||||
|
```ts file=add-valid-todo.spec.ts
|
||||||
|
// spec: specs/plan.md
|
||||||
|
// seed: tests/seed.spec.ts
|
||||||
|
|
||||||
|
test.describe('Adding New Todos', () => {
|
||||||
|
test('Add Valid Todo', async { page } => {
|
||||||
|
// 1. Click in the "What needs to be done?" input field
|
||||||
|
await page.click(...);
|
||||||
|
|
||||||
|
...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</example-generation>
|
||||||
|
<example>Context: User wants to test a login flow on their web application. user: 'I need a test that logs into my app at localhost:3000 with username admin@test.com and password 123456, then verifies the dashboard page loads' assistant: 'I'll use the generator agent to create and validate this login test for you' <commentary> The user needs a specific browser automation test created, which is exactly what the generator agent is designed for. </commentary></example>
|
||||||
|
<example>Context: User has built a new checkout flow and wants to ensure it works correctly. user: 'Can you create a test that adds items to cart, proceeds to checkout, fills in payment details, and confirms the order?' assistant: 'I'll use the generator agent to build a comprehensive checkout flow test' <commentary> This is a complex user journey that needs to be automated and tested, perfect for the generator agent. </commentary></example>
|
||||||
44
.github/chatmodes/🎭 healer.chatmode.md
vendored
Normal file
44
.github/chatmodes/🎭 healer.chatmode.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
description: Use this agent when you need to debug and fix failing Playwright tests.
|
||||||
|
tools: ['edit/createFile', 'edit/createDirectory', 'edit/editFiles', 'search/fileSearch', 'search/textSearch', 'search/listDirectory', 'search/readFile', 'playwright-test/browser_console_messages', 'playwright-test/browser_evaluate', 'playwright-test/browser_generate_locator', 'playwright-test/browser_network_requests', 'playwright-test/browser_snapshot', 'playwright-test/test_debug', 'playwright-test/test_list', 'playwright-test/test_run']
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
|
||||||
|
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
|
||||||
|
broken Playwright tests using a methodical approach.
|
||||||
|
|
||||||
|
Your workflow:
|
||||||
|
1. **Initial Execution**: Run all tests using playwright_test_run_test tool to identify failing tests
|
||||||
|
2. **Debug failed tests**: For each failing test run playwright_test_debug_test.
|
||||||
|
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
|
||||||
|
- Examine the error details
|
||||||
|
- Capture page snapshot to understand the context
|
||||||
|
- Analyze selectors, timing issues, or assertion failures
|
||||||
|
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
|
||||||
|
- Element selectors that may have changed
|
||||||
|
- Timing and synchronization issues
|
||||||
|
- Data dependencies or test environment problems
|
||||||
|
- Application changes that broke test assumptions
|
||||||
|
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
|
||||||
|
- Updating selectors to match current application state
|
||||||
|
- Fixing assertions and expected values
|
||||||
|
- Improving test reliability and maintainability
|
||||||
|
- For inherently dynamic data, utilize regular expressions to produce resilient locators
|
||||||
|
6. **Verification**: Restart the test after each fix to validate the changes
|
||||||
|
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
|
||||||
|
|
||||||
|
Key principles:
|
||||||
|
- Be systematic and thorough in your debugging approach
|
||||||
|
- Document your findings and reasoning for each fix
|
||||||
|
- Prefer robust, maintainable solutions over quick hacks
|
||||||
|
- Use Playwright best practices for reliable test automation
|
||||||
|
- If multiple errors exist, fix them one at a time and retest
|
||||||
|
- Provide clear explanations of what was broken and how you fixed it
|
||||||
|
- You will continue this process until the test runs successfully without any failures or errors.
|
||||||
|
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
|
||||||
|
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
|
||||||
|
of the expected behavior.
|
||||||
|
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
|
||||||
|
- Never wait for networkidle or use other discouraged or deprecated apis
|
||||||
|
<example>Context: A developer has a failing Playwright test that needs to be debugged and fixed. user: 'The login test is failing, can you fix it?' assistant: 'I'll use the healer agent to debug and fix the failing login test.' <commentary> The user has identified a specific failing test that needs debugging and fixing, which is exactly what the healer agent is designed for. </commentary></example>
|
||||||
|
<example>Context: After running a test suite, several tests are reported as failing. user: 'Test user-registration.spec.ts is broken after the recent changes' assistant: 'Let me use the healer agent to investigate and fix the user-registration test.' <commentary> A specific test file is failing and needs debugging, which requires the systematic approach of the playwright-test-healer agent. </commentary></example>
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,11 @@
|
|||||||
/frontend/node_modules
|
/frontend/node_modules
|
||||||
|
/frontend/build/
|
||||||
|
/frontend/dist/
|
||||||
/backend/node_modules
|
/backend/node_modules
|
||||||
|
/backend/build/
|
||||||
|
/backend/dist/
|
||||||
/.context
|
/.context
|
||||||
.env
|
.env
|
||||||
|
/node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report:
|
Sync Impact Report:
|
||||||
- Version change: 0.0.0 → 1.0.0
|
- Version change: 1.1.0 → 1.1.1
|
||||||
- List of modified principles: N/A (initial creation)
|
- List of modified principles: I. Defined Technology Stack (refinement)
|
||||||
- Added sections: Core Principles, Governance
|
- Added sections: None
|
||||||
- Removed sections: N/A
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md (verified)
|
- ✅ .specify/templates/plan-template.md (verified)
|
||||||
- ✅ .specify/templates/spec-template.md (verified)
|
- ✅ .specify/templates/spec-template.md (verified)
|
||||||
- ✅ .specify/templates/tasks-template.md (verified)
|
- ✅ .specify/templates/tasks-template.md (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.analyze.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.checklist.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.clarify.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.constitution.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.implement.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.plan.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.specify.toml (verified)
|
||||||
|
- ✅ .gemini/commands/speckit.tasks.toml (verified)
|
||||||
|
- ⚠ README.md (pending re-check)
|
||||||
|
- ⚠ GEMINI.md (pending re-check)
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
-->
|
-->
|
||||||
# Agree on Desires Constitution
|
# Agree on Desires Constitution
|
||||||
@@ -16,7 +26,7 @@ Sync Impact Report:
|
|||||||
|
|
||||||
### I. Defined Technology Stack
|
### I. Defined Technology Stack
|
||||||
All development MUST adhere to the approved technology stack. This ensures consistency, maintainability, and streamlined operations.
|
All development MUST adhere to the approved technology stack. This ensures consistency, maintainability, and streamlined operations.
|
||||||
- **Backend**: Node.js
|
- **Full-stack Framework**: Ruby on Rails (latest stable version)
|
||||||
- **Frontend**: React
|
- **Frontend**: React
|
||||||
- **UI Framework**: Material Design 3 (Material-UI / MUI)
|
- **UI Framework**: Material Design 3 (Material-UI / MUI)
|
||||||
- **Containerization**: Docker
|
- **Containerization**: Docker
|
||||||
@@ -37,4 +47,4 @@ The backend and frontend are decoupled and communicate via a well-defined API co
|
|||||||
|
|
||||||
All development activities, code reviews, and architectural decisions must align with this constitution. Proposed deviations require a formal amendment to this document.
|
All development activities, code reviews, and architectural decisions must align with this constitution. Proposed deviations require a formal amendment to this document.
|
||||||
|
|
||||||
**Version**: 1.0.0 | **Ratified**: 2025-10-09 | **Last Amended**: 2025-10-09
|
**Version**: 1.1.1 | **Ratified**: 2025-10-09 | **Last Amended**: 2025-10-17
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
# - Creates default Claude file if no agent files exist
|
# - Creates default Claude file if no agent files exist
|
||||||
#
|
#
|
||||||
# Usage: ./update-agent-context.sh [agent_type]
|
# Usage: ./update-agent-context.sh [agent_type]
|
||||||
# Agent types: claude|gemini|copilot|cursor|qwen|opencode|codex|windsurf|kilocode|auggie|q
|
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|q
|
||||||
# Leave empty to update all existing agent files
|
# Leave empty to update all existing agent files
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -69,6 +69,7 @@ WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
|||||||
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
||||||
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||||
|
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||||
Q_FILE="$REPO_ROOT/AGENTS.md"
|
Q_FILE="$REPO_ROOT/AGENTS.md"
|
||||||
|
|
||||||
# Template file
|
# Template file
|
||||||
@@ -557,7 +558,7 @@ update_specific_agent() {
|
|||||||
copilot)
|
copilot)
|
||||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||||
;;
|
;;
|
||||||
cursor)
|
cursor-agent)
|
||||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||||
;;
|
;;
|
||||||
qwen)
|
qwen)
|
||||||
@@ -581,12 +582,15 @@ update_specific_agent() {
|
|||||||
roo)
|
roo)
|
||||||
update_agent_file "$ROO_FILE" "Roo Code"
|
update_agent_file "$ROO_FILE" "Roo Code"
|
||||||
;;
|
;;
|
||||||
|
codebuddy)
|
||||||
|
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy"
|
||||||
|
;;
|
||||||
q)
|
q)
|
||||||
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "Unknown agent type '$agent_type'"
|
log_error "Unknown agent type '$agent_type'"
|
||||||
log_error "Expected: claude|gemini|copilot|cursor|qwen|opencode|codex|windsurf|kilocode|auggie|roo|q"
|
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|q"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -646,6 +650,11 @@ update_all_existing_agents() {
|
|||||||
found_agent=true
|
found_agent=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$CODEBUDDY_FILE" ]]; then
|
||||||
|
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy"
|
||||||
|
found_agent=true
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -f "$Q_FILE" ]]; then
|
if [[ -f "$Q_FILE" ]]; then
|
||||||
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
update_agent_file "$Q_FILE" "Amazon Q Developer CLI"
|
||||||
found_agent=true
|
found_agent=true
|
||||||
@@ -674,7 +683,8 @@ print_summary() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
log_info "Usage: $0 [claude|gemini|copilot|cursor|qwen|opencode|codex|windsurf|kilocode|auggie|q]"
|
|
||||||
|
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|codebuddy|q]"
|
||||||
}
|
}
|
||||||
|
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
|
|||||||
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"playwright-test": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"playwright",
|
||||||
|
"run-test-mcp-server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputs": []
|
||||||
|
}
|
||||||
12
GEMINI.md
12
GEMINI.md
@@ -3,15 +3,15 @@
|
|||||||
Auto-generated from all feature plans. Last updated: 2025-10-13
|
Auto-generated from all feature plans. Last updated: 2025-10-13
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- Node.js (LTS), TypeScript 5.x
|
- Ruby on Rails (latest stable version) as full-stack framework
|
||||||
- React
|
- React
|
||||||
- Material-UI / MUI (for Material Design 3 UI components)
|
- Material-UI / MUI (for Material Design 3 UI components)
|
||||||
- WebSocket library (for real-time communication)
|
- WebSocket library (for real-time communication)
|
||||||
- Google Cloud Natural Language API (for semantic comparison)
|
- Google Cloud Natural Language API (for semantic comparison)
|
||||||
- Ephemeral server-side storage (in-memory/session store) for encrypted session data, purged on session termination.
|
- Ephemeral server-side storage (in-memory/session store) for encrypted session data, purged on session termination.
|
||||||
- Node.js (LTS), TypeScript 5.x + React, Material-UI / MUI, WebSocket library, Express.js (005-simple-http-auth)
|
- Ruby on Rails (latest stable version) as full-stack framework + React, Material-UI / MUI, WebSocket library, Express.js (005-simple-http-auth)
|
||||||
- Ephemeral server-side storage (in-memory/session store) for encrypted session data, `.env` file for passphrase. (005-simple-http-auth)
|
- Ephemeral server-side storage (in-memory/session store) for encrypted session data, `.env` file for passphrase. (005-simple-http-auth)
|
||||||
- Node.js (LTS), TypeScript 5.x, React + Material-UI / MUI, React Router, Browser's native Clipboard API (006-copy-link-feature)
|
- Ruby on Rails (latest stable version) as full-stack framework, React + Material-UI / MUI, React Router, Browser's native Clipboard API (006-copy-link-feature)
|
||||||
- N/A (frontend feature, no direct storage interaction) (006-copy-link-feature)
|
- N/A (frontend feature, no direct storage interaction) (006-copy-link-feature)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -40,11 +40,11 @@ npm test
|
|||||||
npm run lint
|
npm run lint
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
Node.js (LTS), TypeScript 5.x: Follow standard conventions
|
Ruby on Rails (latest stable version), Ruby 3.x: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 006-copy-link-feature: Added Node.js (LTS), TypeScript 5.x, React + Material-UI / MUI, React Router, Browser's native Clipboard API
|
- 006-copy-link-feature: Added Ruby on Rails (LTS), Ruby 3.x, React + Material-UI / MUI, React Router, Browser's native Clipboard API
|
||||||
- 005-simple-http-auth: Added Node.js (LTS), TypeScript 5.x + React, Material-UI / MUI, WebSocket library, Express.js
|
- 005-simple-http-auth: Added Ruby on Rails (LTS), Ruby 3.x + React, Material-UI / MUI, WebSocket library, Express.js
|
||||||
- 004-afraid-to-ask: Implemented ephemeral server-side storage for encrypted session data, WebSocket communication, and Google Cloud Natural Language API.
|
- 004-afraid-to-ask: Implemented ephemeral server-side storage for encrypted session data, WebSocket communication, and Google Cloud Natural Language API.
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ This feature provides a basic HTTP authentication mechanism for the Single Page
|
|||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
This will build the frontend and backend services and start them.
|
This will build the frontend and Ruby on Rails backend services and start them.
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
GEMINI_API_KEY="AIzaSyDke9H2NhiG6rBwxT0qrdYgnNoNZm_0j58"
|
GEMINI_API_KEY="AIzaSyDke9H2NhiG6rBwxT0qrdYgnNoNZm_0j58"
|
||||||
ENCRYPTION_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
|
ENCRYPTION_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
SESSION_TIMEOUT_MINUTES=1
|
||||||
42
backend/dist/index.js
vendored
42
backend/dist/index.js
vendored
@@ -12,14 +12,50 @@ const sessions_1 = __importDefault(require("./routes/sessions"));
|
|||||||
const auth_1 = __importDefault(require("./api/auth"));
|
const auth_1 = __importDefault(require("./api/auth"));
|
||||||
const authMiddleware_1 = require("./middleware/authMiddleware"); // Import the middleware
|
const authMiddleware_1 = require("./middleware/authMiddleware"); // Import the middleware
|
||||||
const cors_1 = __importDefault(require("cors"));
|
const cors_1 = __importDefault(require("cors"));
|
||||||
|
const uuid_1 = require("uuid");
|
||||||
|
const ws_2 = require("./ws"); // Import sessions and SessionState from ws/index.ts
|
||||||
|
console.log('index.ts: AUTH_PASSPHRASE:', process.env.AUTH_PASSPHRASE);
|
||||||
|
console.log('index.ts: SESSION_SECRET:', process.env.SESSION_SECRET);
|
||||||
|
console.log('index.ts: JWT_SECRET:', process.env.JWT_SECRET);
|
||||||
const app = (0, express_1.default)();
|
const app = (0, express_1.default)();
|
||||||
const server = http_1.default.createServer(app);
|
const server = http_1.default.createServer(app);
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express_1.default.json());
|
app.use(express_1.default.json());
|
||||||
app.use((0, cors_1.default)());
|
const allowedOrigins = process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : [];
|
||||||
// API Routes
|
const corsOptions = {
|
||||||
app.use('/', authMiddleware_1.authMiddleware, sessions_1.default); // Apply middleware to sessionsRouter
|
origin: (origin, callback) => {
|
||||||
|
// Allow same-origin requests (origin is undefined) and requests from the whitelisted origins
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.warn(`CORS: Blocked request from origin: ${origin}`);
|
||||||
|
callback(new Error('Not allowed by CORS'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
app.use((0, cors_1.default)(corsOptions));
|
||||||
|
// Public API Routes
|
||||||
app.use('/api/auth', auth_1.default);
|
app.use('/api/auth', auth_1.default);
|
||||||
|
// Public route for creating a new session
|
||||||
|
app.post('/sessions', (req, res) => {
|
||||||
|
const sessionId = (0, uuid_1.v4)();
|
||||||
|
ws_2.sessions.set(sessionId, {
|
||||||
|
state: ws_2.SessionState.SETUP,
|
||||||
|
topic: null,
|
||||||
|
description: null,
|
||||||
|
expectedResponses: 0,
|
||||||
|
submittedCount: 0,
|
||||||
|
responses: new Map(),
|
||||||
|
clients: new Map(),
|
||||||
|
finalResult: null,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
});
|
||||||
|
console.log(`New session created: ${sessionId}`);
|
||||||
|
res.status(201).json({ sessionId });
|
||||||
|
});
|
||||||
|
// Protected API Routes
|
||||||
|
app.use('/sessions', authMiddleware_1.authMiddleware, sessions_1.default);
|
||||||
// Create and attach WebSocket server
|
// Create and attach WebSocket server
|
||||||
(0, ws_1.createWebSocketServer)(server);
|
(0, ws_1.createWebSocketServer)(server);
|
||||||
const PORT = process.env.PORT || 8000;
|
const PORT = process.env.PORT || 8000;
|
||||||
|
|||||||
79
backend/dist/routes/sessions.js
vendored
79
backend/dist/routes/sessions.js
vendored
@@ -14,8 +14,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
const express_1 = __importDefault(require("express"));
|
const express_1 = __importDefault(require("express"));
|
||||||
const uuid_1 = require("uuid");
|
const uuid_1 = require("uuid");
|
||||||
const ws_1 = require("../ws"); // Import sessions, SessionState, broadcastToSession, and handleWebSocketMessage from ws/index.ts
|
const ws_1 = require("../ws"); // Import sessions, SessionState, broadcastToSession from ws/index.ts
|
||||||
|
const LLMService_1 = require("../services/LLMService");
|
||||||
|
const EncryptionService_1 = require("../services/EncryptionService");
|
||||||
const router = express_1.default.Router();
|
const router = express_1.default.Router();
|
||||||
|
// Initialize LLM Service (API key from environment)
|
||||||
|
const llmService = new LLMService_1.LLMService(process.env.GEMINI_API_KEY || '');
|
||||||
|
// Initialize Encryption Service
|
||||||
|
const encryptionService = new EncryptionService_1.EncryptionService(process.env.ENCRYPTION_KEY || '');
|
||||||
router.post('/sessions', (req, res) => {
|
router.post('/sessions', (req, res) => {
|
||||||
const sessionId = (0, uuid_1.v4)();
|
const sessionId = (0, uuid_1.v4)();
|
||||||
ws_1.sessions.set(sessionId, {
|
ws_1.sessions.set(sessionId, {
|
||||||
@@ -27,32 +33,69 @@ router.post('/sessions', (req, res) => {
|
|||||||
responses: new Map(),
|
responses: new Map(),
|
||||||
clients: new Map(),
|
clients: new Map(),
|
||||||
finalResult: null,
|
finalResult: null,
|
||||||
|
lastActivity: Date.now(),
|
||||||
});
|
});
|
||||||
console.log(`New session created: ${sessionId}`);
|
|
||||||
res.status(201).json({ sessionId });
|
res.status(201).json({ sessionId });
|
||||||
});
|
});
|
||||||
router.post('/sessions/:sessionId/responses', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
router.post('/sessions/:sessionId/responses', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
const { userId, wants, accepts, afraidToAsk } = req.body;
|
const { clientId, wants, accepts, noGoes, afraidToAsk } = req.body; // Use clientId instead of userId
|
||||||
if (!ws_1.sessions.has(sessionId)) {
|
if (!ws_1.sessions.has(sessionId)) {
|
||||||
return res.status(404).json({ message: 'Session not found.' });
|
return res.status(404).json({ message: 'Session not found.' });
|
||||||
}
|
}
|
||||||
// Create a dummy WebSocket object for the handleWebSocketMessage function.
|
const sessionData = ws_1.sessions.get(sessionId);
|
||||||
// This is a workaround to reuse the WebSocket message handling logic.
|
if (sessionData.state !== ws_1.SessionState.GATHERING) {
|
||||||
// In a real application, consider a more robust event-driven architecture.
|
return res.status(400).json({ message: `Session is not in GATHERING state. Current state: ${sessionData.state}` });
|
||||||
const dummyWs = {
|
}
|
||||||
send: (message) => console.log('Dummy WS send:', message),
|
if (sessionData.responses.has(clientId)) {
|
||||||
readyState: 1, // OPEN
|
return res.status(400).json({ message: 'You have already submitted a response for this session.' });
|
||||||
};
|
}
|
||||||
const message = {
|
if ([...wants, ...accepts, ...noGoes].some((desire) => desire.length > 500) || afraidToAsk.length > 500) {
|
||||||
type: 'SUBMIT_RESPONSE',
|
return res.status(400).json({ message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' });
|
||||||
clientId: userId,
|
}
|
||||||
payload: {
|
|
||||||
response: { wants, accepts, afraidToAsk },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
yield (0, ws_1.handleWebSocketMessage)(dummyWs, sessionId, message);
|
const hasContradictionsGist = yield llmService.checkForInnerContradictions({ wants, accepts, noGoes, afraidToAsk });
|
||||||
|
if (hasContradictionsGist) {
|
||||||
|
return res.status(400).json({ message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` });
|
||||||
|
}
|
||||||
|
const encryptedWants = wants.map((d) => encryptionService.encrypt(d));
|
||||||
|
const encryptedAccepts = accepts.map((d) => encryptionService.encrypt(d));
|
||||||
|
const encryptedNoGoes = noGoes.map((d) => encryptionService.encrypt(d));
|
||||||
|
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
||||||
|
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
||||||
|
sessionData.submittedCount++;
|
||||||
|
console.log(`Client ${clientId} submitted response via HTTP. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
||||||
|
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
||||||
|
sessionData.state = ws_1.SessionState.HARMONIZING;
|
||||||
|
(0, ws_1.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
||||||
|
// Perform LLM analysis asynchronously
|
||||||
|
(() => __awaiter(void 0, void 0, void 0, function* () {
|
||||||
|
try {
|
||||||
|
const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
||||||
|
const decryptedWants = encryptedResponse.wants.map((d) => encryptionService.decrypt(d));
|
||||||
|
const decryptedAccepts = encryptedResponse.accepts.map((d) => encryptionService.decrypt(d));
|
||||||
|
const decryptedNoGoes = encryptedResponse.noGoes.map((d) => encryptionService.decrypt(d));
|
||||||
|
const decryptedAfraidToAsk = encryptionService.decrypt(encryptedResponse.afraidToAsk);
|
||||||
|
return { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: decryptedAfraidToAsk };
|
||||||
|
});
|
||||||
|
const decision = yield llmService.analyzeDesires(allDecryptedDesires);
|
||||||
|
sessionData.finalResult = decision;
|
||||||
|
sessionData.state = ws_1.SessionState.FINAL;
|
||||||
|
(0, ws_1.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
||||||
|
sessionData.state = ws_1.SessionState.ERROR;
|
||||||
|
(0, ws_1.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
}
|
||||||
|
}))();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Only broadcast the latest count if the session is not yet harmonizing
|
||||||
|
(0, ws_1.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
}
|
||||||
res.status(202).json({ message: 'Response submission acknowledged and processed.' });
|
res.status(202).json({ message: 'Response submission acknowledged and processed.' });
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|||||||
2
backend/dist/services/AuthService.js
vendored
2
backend/dist/services/AuthService.js
vendored
@@ -36,6 +36,8 @@ class AuthService {
|
|||||||
return !!AuthService.passphrase && AuthService.passphrase.trim() !== '';
|
return !!AuthService.passphrase && AuthService.passphrase.trim() !== '';
|
||||||
}
|
}
|
||||||
static validatePassphrase(inputPassphrase) {
|
static validatePassphrase(inputPassphrase) {
|
||||||
|
console.log('AuthService: AUTH_PASSPHRASE from process.env:', process.env.AUTH_PASSPHRASE);
|
||||||
|
console.log('AuthService: Stored passphrase:', AuthService.passphrase);
|
||||||
if (!AuthService.isAuthEnabled()) {
|
if (!AuthService.isAuthEnabled()) {
|
||||||
return true; // If auth is not enabled, any passphrase is "valid"
|
return true; // If auth is not enabled, any passphrase is "valid"
|
||||||
}
|
}
|
||||||
|
|||||||
8
backend/dist/services/LLMService.js
vendored
8
backend/dist/services/LLMService.js
vendored
@@ -24,10 +24,10 @@ class LLMService {
|
|||||||
Each participant's desire set includes 'wants', 'accepts', 'noGoes', and an 'afraidToAsk' field. The 'afraidToAsk' field contains a sensitive idea that the participant is hesitant to express publicly.
|
Each participant's desire set includes 'wants', 'accepts', 'noGoes', and an 'afraidToAsk' field. The 'afraidToAsk' field contains a sensitive idea that the participant is hesitant to express publicly.
|
||||||
|
|
||||||
Here are the rules for categorization and synthesis, with special handling for 'afraidToAsk' ideas:
|
Here are the rules for categorization and synthesis, with special handling for 'afraidToAsk' ideas:
|
||||||
- "goTo": Synthesize a text describing what ALL participants want without contradictions. This should include 'afraidToAsk' ideas that semantically match all other participant's 'wants' or 'afraidToAsk'. If an 'afraidToAsk' idea matches, it should be treated as a 'want' for the submitting participant. Use the more specific opinions and leave all the specific options if they do not contradict each other drastically.
|
- "goTo": Synthesize a text describing what ALL participants want without contradictions. This should include 'afraidToAsk' ideas that semantically match all other participant's 'wants' or 'afraidToAsk'. If an 'afraidToAsk' idea matches, it should be treated as a 'want' for the submitting participant. Use the more specific opinions and keep all the specific options if they do not contradict each other drastically and are not 'noGoes'.
|
||||||
- "alsoGood": Synthesize a text describing what at least one participant wants (including matched 'afraidToAsk' ideas), not everyone wants but all other participants at least accept, and is not a "noGoes" for anyone. This should reflect a generally agreeable outcome. Use the more specific opinions and leave all the specific options if they do not contradict each other drastically.
|
- "alsoGood": Synthesize a text describing what at least one participant wants (including matched 'afraidToAsk' ideas), not everyone wants but all other participants at least accept, and is not a "noGoes" for anyone. This should reflect a generally agreeable outcome. Use the more specific opinions and keep all the specific options if they do not contradict each other drastically and are not 'noGoes'.
|
||||||
- "considerable": Synthesize a text describing what is wanted or accepted by some, but not all, participants (including matched 'afraidToAsk' ideas), and is not a "noGoes" for anyone. This should highlight areas of partial agreement or options that could be explored. Use the more specific opinions and leave all the specific options if they do not contradict each other drastically.
|
- "considerable": Synthesize a text describing what is wanted or accepted by some, but not all, participants (including matched 'afraidToAsk' ideas), and is not a "noGoes" for anyone. This should highlight areas of partial agreement or options that could be explored. Use the more specific opinions and keep all the specific options if they do not contradict each other drastically and are not 'noGoes'.
|
||||||
- "noGoes": Synthesize a text describing what at least ONE participant does not want. This should clearly state the collective exclusions. Use the more broad opinions summarizing all the specific options if they do not contradict each other drastically.
|
- "noGoes": Synthesize a text describing what at least ONE participant does not want. This should clearly state the collective exclusions. Use the more broad opinions summarizing more specific options if they do not contradict each other drastically.
|
||||||
- "needsDiscussion": Synthesize a text describing where there is a direct conflict (e.g., one participant wants it, another does not want it). This should highlight areas requiring further negotiation. Do not include 'afraidToAsk' in this category.
|
- "needsDiscussion": Synthesize a text describing where there is a direct conflict (e.g., one participant wants it, another does not want it). This should highlight areas requiring further negotiation. Do not include 'afraidToAsk' in this category.
|
||||||
|
|
||||||
'AfraidToAsk' ideas that do NOT semantically match any other participant's 'wants' or 'accepts' very closely should remain private and NOT be included in any of the synthesized categories. Matching must use minimal level of generalization.
|
'AfraidToAsk' ideas that do NOT semantically match any other participant's 'wants' or 'accepts' very closely should remain private and NOT be included in any of the synthesized categories. Matching must use minimal level of generalization.
|
||||||
|
|||||||
179
backend/dist/ws/index.js
vendored
179
backend/dist/ws/index.js
vendored
@@ -69,8 +69,23 @@ const broadcastToSession = (sessionId, message, excludeClientId = null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
exports.broadcastToSession = broadcastToSession;
|
exports.broadcastToSession = broadcastToSession;
|
||||||
|
const SESSION_TIMEOUT_MINUTES = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '1440', 10);
|
||||||
|
const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000; // Convert minutes to milliseconds
|
||||||
|
// Function to clean up inactive sessions
|
||||||
|
const cleanupInactiveSessions = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, sessionData] of exports.sessions.entries()) {
|
||||||
|
if (sessionData.clients.size === 0 && (now - sessionData.lastActivity > SESSION_TIMEOUT_MS)) {
|
||||||
|
exports.sessions.delete(sessionId);
|
||||||
|
logEvent('session_purged_inactive', sessionId);
|
||||||
|
console.log(`Inactive session ${sessionId} purged.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const createWebSocketServer = (server) => {
|
const createWebSocketServer = (server) => {
|
||||||
const wss = new ws_1.WebSocketServer({ server });
|
const wss = new ws_1.WebSocketServer({ server });
|
||||||
|
// Schedule periodic cleanup of inactive sessions
|
||||||
|
setInterval(cleanupInactiveSessions, 60 * 60 * 1000); // Run every hour
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
||||||
const sessionId = url.pathname.split('/').pop();
|
const sessionId = url.pathname.split('/').pop();
|
||||||
@@ -78,56 +93,55 @@ const createWebSocketServer = (server) => {
|
|||||||
ws.close(1008, 'Invalid session ID');
|
ws.close(1008, 'Invalid session ID');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!exports.sessions.has(sessionId)) {
|
let sessionData = null;
|
||||||
exports.sessions.set(sessionId, {
|
// Set up a ping interval to keep the connection alive
|
||||||
state: SessionState.SETUP,
|
const pingInterval = setInterval(() => {
|
||||||
topic: null,
|
if (ws.readyState === ws_1.WebSocket.OPEN) {
|
||||||
description: null,
|
ws.ping();
|
||||||
expectedResponses: 0,
|
|
||||||
submittedCount: 0,
|
|
||||||
responses: new Map(),
|
|
||||||
clients: new Map(),
|
|
||||||
finalResult: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const sessionData = exports.sessions.get(sessionId);
|
}, 30000); // Send ping every 30 seconds
|
||||||
console.log(`Client connecting to session: ${sessionId}`);
|
|
||||||
ws.on('message', (message) => __awaiter(void 0, void 0, void 0, function* () {
|
ws.on('message', (message) => __awaiter(void 0, void 0, void 0, function* () {
|
||||||
const parsedMessage = JSON.parse(message.toString());
|
const parsedMessage = JSON.parse(message.toString());
|
||||||
const { type, clientId, payload } = parsedMessage;
|
const updatedSessionData = yield (0, exports.handleWebSocketMessage)(ws, sessionId, parsedMessage);
|
||||||
if (!clientId) {
|
if (updatedSessionData) {
|
||||||
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
sessionData = updatedSessionData;
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!sessionData.clients.has(clientId)) {
|
|
||||||
sessionData.clients.set(clientId, ws);
|
|
||||||
console.log(`Client ${clientId} registered for session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
|
||||||
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
|
||||||
}
|
|
||||||
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
|
|
||||||
yield (0, exports.handleWebSocketMessage)(ws, sessionId, parsedMessage);
|
|
||||||
}));
|
}));
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
clearInterval(pingInterval); // Clear the interval when the connection closes
|
||||||
let disconnectedClientId = null;
|
let disconnectedClientId = null;
|
||||||
for (const [clientId, clientWs] of sessionData.clients.entries()) {
|
const currentSessionData = exports.sessions.get(sessionId); // Retrieve the latest sessionData
|
||||||
|
if (currentSessionData) { // Check if sessionData is not null
|
||||||
|
for (const [clientId, clientWs] of currentSessionData.clients.entries()) {
|
||||||
if (clientWs === ws) {
|
if (clientWs === ws) {
|
||||||
disconnectedClientId = clientId;
|
disconnectedClientId = clientId;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (disconnectedClientId) {
|
if (disconnectedClientId) {
|
||||||
sessionData.clients.delete(disconnectedClientId);
|
currentSessionData.clients.delete(disconnectedClientId);
|
||||||
console.log(`Client ${disconnectedClientId} disconnected from session: ${sessionId}. Remaining clients: ${sessionData.clients.size}`);
|
console.log(`Client ${disconnectedClientId} disconnected from session: ${sessionId}. Remaining clients: ${currentSessionData.clients.size}`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.log(`An unregistered client disconnected from session: ${sessionId}.`);
|
console.log(`An unregistered client disconnected from session: ${sessionId}.`);
|
||||||
}
|
}
|
||||||
if (sessionData.clients.size === 0) {
|
if (currentSessionData.clients.size === 0) {
|
||||||
|
// Only purge session if it's in SETUP, FINAL, or ERROR state
|
||||||
|
if (currentSessionData.state === SessionState.SETUP ||
|
||||||
|
currentSessionData.state === SessionState.FINAL ||
|
||||||
|
currentSessionData.state === SessionState.ERROR) {
|
||||||
exports.sessions.delete(sessionId);
|
exports.sessions.delete(sessionId);
|
||||||
logEvent('session_purged', sessionId);
|
logEvent('session_purged', sessionId);
|
||||||
console.log(`Session ${sessionId} closed and state cleared.`);
|
console.log(`Session ${sessionId} closed and state cleared.`);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
console.log(`Session ${sessionId} is in ${currentSessionData.state} state. Not purging despite no active clients.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log(`Client disconnected from session: ${sessionId}. Session data was null.`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (error) => {
|
||||||
console.error(`WebSocket error in session ${sessionId}:`, error);
|
console.error(`WebSocket error in session ${sessionId}:`, error);
|
||||||
@@ -141,25 +155,67 @@ const handleWebSocketMessage = (ws, sessionId, parsedMessage) => __awaiter(void
|
|||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
||||||
return;
|
return exports.sessions.get(sessionId) || null; // Return current session state if available
|
||||||
}
|
}
|
||||||
const sessionData = exports.sessions.get(sessionId);
|
let sessionData = exports.sessions.get(sessionId);
|
||||||
|
// Update lastActivity timestamp on any message
|
||||||
|
if (sessionData) {
|
||||||
|
sessionData.lastActivity = Date.now();
|
||||||
|
console.log(`Session ${sessionId}: lastActivity updated to ${sessionData.lastActivity}`);
|
||||||
|
}
|
||||||
|
// If sessionData is null here, it means a JOIN_SESSION message hasn't been processed yet for a new session.
|
||||||
|
// The JOIN_SESSION case will handle its creation.
|
||||||
|
if (!sessionData && type !== 'JOIN_SESSION') {
|
||||||
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session not found and message is not JOIN_SESSION' } }));
|
||||||
|
return null; // Session not found, return null
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case 'JOIN_SESSION':
|
||||||
|
if (!sessionData) {
|
||||||
|
// Create a new session if it doesn't exist
|
||||||
|
const newSessionData = {
|
||||||
|
state: SessionState.SETUP,
|
||||||
|
topic: null,
|
||||||
|
description: null,
|
||||||
|
expectedResponses: 0,
|
||||||
|
submittedCount: 0,
|
||||||
|
responses: new Map(),
|
||||||
|
clients: new Map(),
|
||||||
|
finalResult: null,
|
||||||
|
lastActivity: Date.now(), // Initialize lastActivity
|
||||||
|
};
|
||||||
|
exports.sessions.set(sessionId, newSessionData); // Explicitly set in global map
|
||||||
|
sessionData = newSessionData; // Update local reference to the newly created session
|
||||||
|
console.log(`New session ${sessionId} created upon client ${clientId} joining. lastActivity: ${sessionData.lastActivity}`);
|
||||||
|
}
|
||||||
|
// Register the client to the session's clients map
|
||||||
if (!sessionData.clients.has(clientId)) {
|
if (!sessionData.clients.has(clientId)) {
|
||||||
sessionData.clients.set(clientId, ws);
|
sessionData.clients.set(clientId, ws);
|
||||||
console.log(`Client ${clientId} registered for session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
console.log(`Client ${clientId} joined session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
||||||
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
|
||||||
}
|
}
|
||||||
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
|
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
||||||
switch (type) {
|
console.log(`Client ${clientId} received STATE_UPDATE for session ${sessionId} upon joining.`);
|
||||||
case 'REGISTER_CLIENT':
|
return sessionData; // Return the updated sessionData
|
||||||
console.log(`Client ${clientId} registered successfully for session ${sessionId}.`);
|
case 'PING':
|
||||||
break;
|
if (!sessionData) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session data not available for PING' } }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Respond to client pings with a pong
|
||||||
|
if (ws.readyState === ws_1.WebSocket.OPEN) {
|
||||||
|
ws.pong();
|
||||||
|
}
|
||||||
|
return sessionData || null; // Return current sessionData or null if undefined
|
||||||
case 'SETUP_SESSION':
|
case 'SETUP_SESSION':
|
||||||
|
if (!sessionData) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session data not available for SETUP_SESSION' } }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (sessionData.state === SessionState.SETUP) {
|
if (sessionData.state === SessionState.SETUP) {
|
||||||
const { expectedResponses, topic, description } = payload;
|
const { expectedResponses, topic, description } = payload;
|
||||||
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
|
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
|
||||||
return;
|
return sessionData || null; // Return current sessionData on error
|
||||||
}
|
}
|
||||||
sessionData.expectedResponses = expectedResponses;
|
sessionData.expectedResponses = expectedResponses;
|
||||||
sessionData.topic = topic || 'Untitled Session';
|
sessionData.topic = topic || 'Untitled Session';
|
||||||
@@ -171,22 +227,26 @@ const handleWebSocketMessage = (ws, sessionId, parsedMessage) => __awaiter(void
|
|||||||
else {
|
else {
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
|
||||||
}
|
}
|
||||||
break;
|
return sessionData || null; // Return current sessionData
|
||||||
case 'SUBMIT_RESPONSE':
|
case 'SUBMIT_RESPONSE':
|
||||||
|
if (!sessionData) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session data not available for SUBMIT_RESPONSE' } }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (sessionData.state === SessionState.GATHERING) {
|
if (sessionData.state === SessionState.GATHERING) {
|
||||||
if (sessionData.responses.has(clientId)) {
|
if (sessionData.responses.has(clientId)) {
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
|
||||||
return;
|
return sessionData || null; // Return current sessionData on error
|
||||||
}
|
}
|
||||||
const { wants, accepts, noGoes, afraidToAsk } = payload.response;
|
const { wants, accepts, noGoes, afraidToAsk } = payload.response;
|
||||||
if ([...wants, ...accepts, ...noGoes].some(desire => desire.length > 500) || afraidToAsk.length > 500) {
|
if ([...wants, ...accepts, ...noGoes].some((desire) => desire.length > 500) || afraidToAsk.length > 500) {
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' } }));
|
||||||
return;
|
return sessionData || null; // Return current sessionData on error
|
||||||
}
|
}
|
||||||
const hasContradictionsGist = yield llmService.checkForInnerContradictions(payload.response);
|
const hasContradictionsGist = yield llmService.checkForInnerContradictions(payload.response);
|
||||||
if (hasContradictionsGist) {
|
if (hasContradictionsGist) {
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` } }));
|
||||||
return;
|
return sessionData || null; // Return current sessionData on error
|
||||||
}
|
}
|
||||||
const encryptedWants = wants.map((d) => encryptionService.encrypt(d));
|
const encryptedWants = wants.map((d) => encryptionService.encrypt(d));
|
||||||
const encryptedAccepts = accepts.map((d) => encryptionService.encrypt(d));
|
const encryptedAccepts = accepts.map((d) => encryptionService.encrypt(d));
|
||||||
@@ -194,18 +254,15 @@ const handleWebSocketMessage = (ws, sessionId, parsedMessage) => __awaiter(void
|
|||||||
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
||||||
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
||||||
sessionData.submittedCount++;
|
sessionData.submittedCount++;
|
||||||
logEvent('response_submitted', sessionId, { clientId, submittedCount: sessionData.submittedCount });
|
|
||||||
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
||||||
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
||||||
sessionData.state = SessionState.HARMONIZING;
|
sessionData.state = SessionState.HARMONIZING;
|
||||||
(0, exports.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
(0, exports.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
logEvent('session_harmonizing', sessionId, { expectedResponses: sessionData.expectedResponses });
|
|
||||||
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
||||||
// Perform LLM analysis asynchronously
|
// Perform LLM analysis asynchronously
|
||||||
(() => __awaiter(void 0, void 0, void 0, function* () {
|
(() => __awaiter(void 0, void 0, void 0, function* () {
|
||||||
let durationMs = 0; // Declare here
|
|
||||||
try {
|
try {
|
||||||
logEvent('llm_analysis_started', sessionId);
|
console.log('llm_analysis_started', sessionId);
|
||||||
const startTime = process.hrtime.bigint();
|
const startTime = process.hrtime.bigint();
|
||||||
const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
||||||
const decryptedWants = encryptedResponse.wants.map((d) => encryptionService.decrypt(d));
|
const decryptedWants = encryptedResponse.wants.map((d) => encryptionService.decrypt(d));
|
||||||
@@ -218,17 +275,17 @@ const handleWebSocketMessage = (ws, sessionId, parsedMessage) => __awaiter(void
|
|||||||
sessionData.finalResult = decision;
|
sessionData.finalResult = decision;
|
||||||
sessionData.state = SessionState.FINAL;
|
sessionData.state = SessionState.FINAL;
|
||||||
(0, exports.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
(0, exports.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
logEvent('llm_analysis_completed', sessionId, { result: decision });
|
console.log('llm_analysis_completed', sessionId, { result: decision });
|
||||||
recordMetric('llm_analysis_duration', durationMs, sessionId, { status: 'success' });
|
console.log('llm_analysis_duration', 0, sessionId, { status: 'success' });
|
||||||
recordMetric('llm_analysis_availability', 'available', sessionId);
|
console.log('llm_analysis_availability', 'available', sessionId);
|
||||||
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
||||||
sessionData.state = SessionState.ERROR;
|
sessionData.state = SessionState.ERROR;
|
||||||
(0, exports.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
(0, exports.broadcastToSession)(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
logEvent('llm_analysis_error', sessionId, { error: error.message });
|
console.log('llm_analysis_error', sessionId, { error: error.message });
|
||||||
recordMetric('llm_analysis_availability', 'unavailable', sessionId, { error: error.message });
|
console.log('llm_analysis_availability', 'unavailable', sessionId, { error: error.message });
|
||||||
}
|
}
|
||||||
}))();
|
}))();
|
||||||
}
|
}
|
||||||
@@ -240,11 +297,23 @@ const handleWebSocketMessage = (ws, sessionId, parsedMessage) => __awaiter(void
|
|||||||
else {
|
else {
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
|
||||||
}
|
}
|
||||||
break;
|
return sessionData || null; // Return current sessionData
|
||||||
default:
|
default:
|
||||||
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
|
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
|
||||||
break;
|
return sessionData || null; // Return current sessionData
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
exports.handleWebSocketMessage = handleWebSocketMessage;
|
exports.handleWebSocketMessage = handleWebSocketMessage;
|
||||||
|
const cleanupInactiveSessions = () => {
|
||||||
|
console.log('Running cleanupInactiveSessions...');
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, sessionData] of exports.sessions.entries()) {
|
||||||
|
console.log(`Session ${sessionId}: clients.size=${sessionData.clients.size}, lastActivity=${sessionData.lastActivity}, timeSinceLastActivity=${now - sessionData.lastActivity}, SESSION_TIMEOUT_MS=${SESSION_TIMEOUT_MS}`);
|
||||||
|
if (sessionData.clients.size === 0 && (now - sessionData.lastActivity > SESSION_TIMEOUT_MS)) {
|
||||||
|
exports.sessions.delete(sessionId);
|
||||||
|
logEvent('session_purged_inactive', sessionId);
|
||||||
|
console.log(`Inactive session ${sessionId} purged.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ app.post('/sessions', (req, res) => {
|
|||||||
responses: new Map(),
|
responses: new Map(),
|
||||||
clients: new Map(),
|
clients: new Map(),
|
||||||
finalResult: null,
|
finalResult: null,
|
||||||
|
lastActivity: Date.now(),
|
||||||
});
|
});
|
||||||
console.log(`New session created: ${sessionId}`);
|
console.log(`New session created: ${sessionId}`);
|
||||||
res.status(201).json({ sessionId });
|
res.status(201).json({ sessionId });
|
||||||
|
|||||||
@@ -1,35 +1,104 @@
|
|||||||
|
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { sessions, SessionState, broadcastToSession, handleWebSocketMessage } from '../ws'; // Import sessions, SessionState, broadcastToSession, and handleWebSocketMessage from ws/index.ts
|
import { sessions, SessionState, broadcastToSession } from '../ws'; // Import sessions, SessionState, broadcastToSession from ws/index.ts
|
||||||
|
import { LLMService } from '../services/LLMService';
|
||||||
|
import { EncryptionService } from '../services/EncryptionService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Initialize LLM Service (API key from environment)
|
||||||
|
const llmService = new LLMService(process.env.GEMINI_API_KEY || '');
|
||||||
|
// Initialize Encryption Service
|
||||||
|
const encryptionService = new EncryptionService(process.env.ENCRYPTION_KEY || '');
|
||||||
|
|
||||||
|
router.post('/sessions', (req, res) => {
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
state: SessionState.SETUP,
|
||||||
|
topic: null,
|
||||||
|
description: null,
|
||||||
|
expectedResponses: 0,
|
||||||
|
submittedCount: 0,
|
||||||
|
responses: new Map(),
|
||||||
|
clients: new Map(),
|
||||||
|
finalResult: null,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
});
|
||||||
|
res.status(201).json({ sessionId });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/sessions/:sessionId/responses', async (req, res) => {
|
router.post('/sessions/:sessionId/responses', async (req, res) => {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
const { userId, wants, accepts, afraidToAsk } = req.body;
|
const { clientId, wants, accepts, noGoes, afraidToAsk } = req.body; // Use clientId instead of userId
|
||||||
|
|
||||||
if (!sessions.has(sessionId)) {
|
if (!sessions.has(sessionId)) {
|
||||||
return res.status(404).json({ message: 'Session not found.' });
|
return res.status(404).json({ message: 'Session not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a dummy WebSocket object for the handleWebSocketMessage function.
|
const sessionData = sessions.get(sessionId)!;
|
||||||
// This is a workaround to reuse the WebSocket message handling logic.
|
|
||||||
// In a real application, consider a more robust event-driven architecture.
|
|
||||||
const dummyWs: any = {
|
|
||||||
send: (message: string) => console.log('Dummy WS send:', message),
|
|
||||||
readyState: 1, // OPEN
|
|
||||||
};
|
|
||||||
|
|
||||||
const message = {
|
if (sessionData.state !== SessionState.GATHERING) {
|
||||||
type: 'SUBMIT_RESPONSE',
|
return res.status(400).json({ message: `Session is not in GATHERING state. Current state: ${sessionData.state}` });
|
||||||
clientId: userId, // Using userId as clientId for simplicity in this context
|
}
|
||||||
payload: {
|
|
||||||
response: { wants, accepts, afraidToAsk },
|
if (sessionData.responses.has(clientId)) {
|
||||||
},
|
return res.status(400).json({ message: 'You have already submitted a response for this session.' });
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if ([...wants, ...accepts, ...noGoes].some((desire: string) => desire.length > 500) || afraidToAsk.length > 500) {
|
||||||
|
return res.status(400).json({ message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleWebSocketMessage(dummyWs, sessionId, message);
|
const hasContradictionsGist = await llmService.checkForInnerContradictions({ wants, accepts, noGoes, afraidToAsk });
|
||||||
|
if (hasContradictionsGist) {
|
||||||
|
return res.status(400).json({ message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedWants = wants.map((d: string) => encryptionService.encrypt(d));
|
||||||
|
const encryptedAccepts = accepts.map((d: string) => encryptionService.encrypt(d));
|
||||||
|
const encryptedNoGoes = noGoes.map((d: string) => encryptionService.encrypt(d));
|
||||||
|
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
||||||
|
|
||||||
|
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
||||||
|
sessionData.submittedCount++;
|
||||||
|
|
||||||
|
console.log(`Client ${clientId} submitted response via HTTP. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
||||||
|
|
||||||
|
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
||||||
|
sessionData.state = SessionState.HARMONIZING;
|
||||||
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
||||||
|
|
||||||
|
// Perform LLM analysis asynchronously
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
||||||
|
const decryptedWants = encryptedResponse.wants.map((d: string) => encryptionService.decrypt(d));
|
||||||
|
const decryptedAccepts = encryptedResponse.accepts.map((d: string) => encryptionService.decrypt(d));
|
||||||
|
const decryptedNoGoes = encryptedResponse.noGoes.map((d: string) => encryptionService.decrypt(d));
|
||||||
|
const decryptedAfraidToAsk = encryptionService.decrypt(encryptedResponse.afraidToAsk);
|
||||||
|
return { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: decryptedAfraidToAsk };
|
||||||
|
});
|
||||||
|
const decision = await llmService.analyzeDesires(allDecryptedDesires);
|
||||||
|
|
||||||
|
sessionData.finalResult = decision;
|
||||||
|
sessionData.state = SessionState.FINAL;
|
||||||
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
||||||
|
sessionData.state = SessionState.ERROR;
|
||||||
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
// Only broadcast the latest count if the session is not yet harmonizing
|
||||||
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
}
|
||||||
|
|
||||||
res.status(202).json({ message: 'Response submission acknowledged and processed.' });
|
res.status(202).json({ message: 'Response submission acknowledged and processed.' });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error processing response via HTTP route:', error);
|
console.error('Error processing response via HTTP route:', error);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface SessionData {
|
|||||||
responses: Map<string, EncryptedResponseData>; // Stores the submitted desire objects. Map<ClientID, EncryptedResponseData>
|
responses: Map<string, EncryptedResponseData>; // Stores the submitted desire objects. Map<ClientID, EncryptedResponseData>
|
||||||
clients: Map<string, WebSocket>; // Maps the persistent Client ID to their active WebSocket connection object.
|
clients: Map<string, WebSocket>; // Maps the persistent Client ID to their active WebSocket connection object.
|
||||||
finalResult: any | null; // The result returned by the LLM.
|
finalResult: any | null; // The result returned by the LLM.
|
||||||
|
lastActivity: number; // Timestamp of the last activity (e.g., message, client join/leave)
|
||||||
}
|
}
|
||||||
export const sessions = new Map<string, SessionData>();
|
export const sessions = new Map<string, SessionData>();
|
||||||
|
|
||||||
@@ -102,9 +103,27 @@ export const broadcastToSession = (sessionId: string, message: any, excludeClien
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SESSION_TIMEOUT_MINUTES = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '1440', 10);
|
||||||
|
const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000; // Convert minutes to milliseconds
|
||||||
|
|
||||||
|
// Function to clean up inactive sessions
|
||||||
|
const cleanupInactiveSessions = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, sessionData] of sessions.entries()) {
|
||||||
|
if (sessionData.clients.size === 0 && (now - sessionData.lastActivity > SESSION_TIMEOUT_MS)) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
logEvent('session_purged_inactive', sessionId);
|
||||||
|
console.log(`Inactive session ${sessionId} purged.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createWebSocketServer = (server: any) => {
|
export const createWebSocketServer = (server: any) => {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
// Schedule periodic cleanup of inactive sessions
|
||||||
|
setInterval(cleanupInactiveSessions, 60 * 60 * 1000); // Run every hour
|
||||||
|
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
||||||
const sessionId = url.pathname.split('/').pop();
|
const sessionId = url.pathname.split('/').pop();
|
||||||
@@ -114,45 +133,29 @@ export const createWebSocketServer = (server: any) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessions.has(sessionId)) {
|
|
||||||
sessions.set(sessionId, {
|
|
||||||
state: SessionState.SETUP,
|
|
||||||
topic: null,
|
|
||||||
description: null,
|
|
||||||
expectedResponses: 0,
|
|
||||||
submittedCount: 0,
|
|
||||||
responses: new Map<string, any>(),
|
|
||||||
clients: new Map<string, WebSocket>(),
|
|
||||||
finalResult: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const sessionData = sessions.get(sessionId)!;
|
|
||||||
|
|
||||||
console.log(`Client connecting to session: ${sessionId}`);
|
|
||||||
|
let sessionData: SessionData | null = null;
|
||||||
|
|
||||||
|
// Set up a ping interval to keep the connection alive
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.ping();
|
||||||
|
}
|
||||||
|
}, 30000); // Send ping every 30 seconds
|
||||||
|
|
||||||
ws.on('message', async (message) => {
|
ws.on('message', async (message) => {
|
||||||
const parsedMessage = JSON.parse(message.toString());
|
const parsedMessage = JSON.parse(message.toString());
|
||||||
const { type, clientId, payload } = parsedMessage;
|
const updatedSessionData = await handleWebSocketMessage(ws, sessionId, parsedMessage);
|
||||||
|
if (updatedSessionData) {
|
||||||
if (!clientId) {
|
sessionData = updatedSessionData;
|
||||||
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}); ws.on('close', () => {
|
||||||
if (!sessionData.clients.has(clientId)) {
|
clearInterval(pingInterval); // Clear the interval when the connection closes
|
||||||
sessionData.clients.set(clientId, ws);
|
|
||||||
console.log(`Client ${clientId} registered for session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
|
||||||
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
|
|
||||||
await handleWebSocketMessage(ws, sessionId, parsedMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
let disconnectedClientId: string | null = null;
|
let disconnectedClientId: string | null = null;
|
||||||
for (const [clientId, clientWs] of sessionData.clients.entries()) {
|
const currentSessionData = sessions.get(sessionId); // Retrieve the latest sessionData
|
||||||
|
if (currentSessionData) { // Check if sessionData is not null
|
||||||
|
for (const [clientId, clientWs] of currentSessionData.clients.entries()) {
|
||||||
if (clientWs === ws) {
|
if (clientWs === ws) {
|
||||||
disconnectedClientId = clientId;
|
disconnectedClientId = clientId;
|
||||||
break;
|
break;
|
||||||
@@ -160,16 +163,26 @@ export const createWebSocketServer = (server: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (disconnectedClientId) {
|
if (disconnectedClientId) {
|
||||||
sessionData.clients.delete(disconnectedClientId);
|
currentSessionData.clients.delete(disconnectedClientId);
|
||||||
console.log(`Client ${disconnectedClientId} disconnected from session: ${sessionId}. Remaining clients: ${sessionData.clients.size}`);
|
console.log(`Client ${disconnectedClientId} disconnected from session: ${sessionId}. Remaining clients: ${currentSessionData.clients.size}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`An unregistered client disconnected from session: ${sessionId}.`);
|
console.log(`An unregistered client disconnected from session: ${sessionId}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionData.clients.size === 0) {
|
if (currentSessionData.clients.size === 0) {
|
||||||
|
// Only purge session if it's in SETUP, FINAL, or ERROR state
|
||||||
|
if (currentSessionData.state === SessionState.SETUP ||
|
||||||
|
currentSessionData.state === SessionState.FINAL ||
|
||||||
|
currentSessionData.state === SessionState.ERROR) {
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
logEvent('session_purged', sessionId);
|
logEvent('session_purged', sessionId);
|
||||||
console.log(`Session ${sessionId} closed and state cleared.`);
|
console.log(`Session ${sessionId} closed and state cleared.`);
|
||||||
|
} else {
|
||||||
|
console.log(`Session ${sessionId} is in ${currentSessionData.state} state. Not purging despite no active clients.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Client disconnected from session: ${sessionId}. Session data was null.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,124 +194,313 @@ export const createWebSocketServer = (server: any) => {
|
|||||||
return wss;
|
return wss;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleWebSocketMessage = async (ws: WebSocket, sessionId: string, parsedMessage: any) => {
|
export const handleWebSocketMessage = async (ws: WebSocket, sessionId: string, parsedMessage: any): Promise<SessionData | null> => {
|
||||||
const { type, clientId, payload } = parsedMessage;
|
const { type, clientId, payload } = parsedMessage;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
|
||||||
return;
|
return sessions.get(sessionId) || null; // Return current session state if available
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionData = sessions.get(sessionId)!;
|
let sessionData = sessions.get(sessionId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Update lastActivity timestamp on any message
|
||||||
|
|
||||||
|
if (sessionData) {
|
||||||
|
|
||||||
|
sessionData.lastActivity = Date.now();
|
||||||
|
|
||||||
|
console.log(`Session ${sessionId}: lastActivity updated to ${sessionData.lastActivity}`);
|
||||||
|
|
||||||
if (!sessionData.clients.has(clientId)) {
|
|
||||||
sessionData.clients.set(clientId, ws);
|
|
||||||
console.log(`Client ${clientId} registered for session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
|
||||||
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
|
|
||||||
|
|
||||||
|
// If sessionData is null here, it means a JOIN_SESSION message hasn't been processed yet for a new session.
|
||||||
|
|
||||||
|
// The JOIN_SESSION case will handle its creation.
|
||||||
|
|
||||||
|
if (!sessionData && type !== 'JOIN_SESSION') {
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session not found and message is not JOIN_SESSION' } }));
|
||||||
|
|
||||||
|
return null; // Session not found, return null
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'REGISTER_CLIENT':
|
|
||||||
console.log(`Client ${clientId} registered successfully for session ${sessionId}.`);
|
case 'JOIN_SESSION':
|
||||||
break;
|
|
||||||
|
if (!sessionData) {
|
||||||
|
|
||||||
|
// Create a new session if it doesn't exist
|
||||||
|
|
||||||
|
const newSessionData: SessionData = {
|
||||||
|
|
||||||
|
state: SessionState.SETUP,
|
||||||
|
|
||||||
|
topic: null,
|
||||||
|
|
||||||
|
description: null,
|
||||||
|
|
||||||
|
expectedResponses: 0,
|
||||||
|
|
||||||
|
submittedCount: 0,
|
||||||
|
|
||||||
|
responses: new Map<string, any>(),
|
||||||
|
|
||||||
|
clients: new Map<string, WebSocket>(),
|
||||||
|
|
||||||
|
finalResult: null,
|
||||||
|
|
||||||
|
lastActivity: Date.now(), // Initialize lastActivity
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions.set(sessionId, newSessionData); // Explicitly set in global map
|
||||||
|
|
||||||
|
sessionData = newSessionData; // Update local reference to the newly created session
|
||||||
|
|
||||||
|
console.log(`New session ${sessionId} created upon client ${clientId} joining. lastActivity: ${sessionData.lastActivity}`);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Register the client to the session's clients map
|
||||||
|
|
||||||
|
if (!sessionData.clients.has(clientId)) {
|
||||||
|
|
||||||
|
sessionData.clients.set(clientId, ws);
|
||||||
|
|
||||||
|
console.log(`Client ${clientId} joined session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: getSerializableSession(sessionData, clientId) } }));
|
||||||
|
|
||||||
|
console.log(`Client ${clientId} received STATE_UPDATE for session ${sessionId} upon joining.`);
|
||||||
|
|
||||||
|
return sessionData; // Return the updated sessionData
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
case 'PING':
|
||||||
|
|
||||||
|
if (!sessionData) { ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session data not available for PING' } })); return null; }
|
||||||
|
|
||||||
|
// Respond to client pings with a pong
|
||||||
|
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
|
||||||
|
ws.pong();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionData || null; // Return current sessionData or null if undefined
|
||||||
|
|
||||||
case 'SETUP_SESSION':
|
case 'SETUP_SESSION':
|
||||||
|
|
||||||
|
if (!sessionData) { ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session data not available for SETUP_SESSION' } })); return null; }
|
||||||
|
|
||||||
if (sessionData.state === SessionState.SETUP) {
|
if (sessionData.state === SessionState.SETUP) {
|
||||||
|
|
||||||
const { expectedResponses, topic, description } = payload;
|
const { expectedResponses, topic, description } = payload;
|
||||||
|
|
||||||
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
|
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
|
||||||
return;
|
|
||||||
|
return sessionData || null; // Return current sessionData on error
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionData.expectedResponses = expectedResponses;
|
sessionData.expectedResponses = expectedResponses;
|
||||||
|
|
||||||
sessionData.topic = topic || 'Untitled Session';
|
sessionData.topic = topic || 'Untitled Session';
|
||||||
|
|
||||||
sessionData.description = description || null;
|
sessionData.description = description || null;
|
||||||
|
|
||||||
sessionData.state = SessionState.GATHERING;
|
sessionData.state = SessionState.GATHERING;
|
||||||
|
|
||||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
|
||||||
console.log(`Session ${sessionId} moved to GATHERING with topic "${sessionData.topic}" and ${expectedResponses} expected responses.`);
|
console.log(`Session ${sessionId} moved to GATHERING with topic "${sessionData.topic}" and ${expectedResponses} expected responses.`);
|
||||||
} else {
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
else {
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionData || null; // Return current sessionData
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
case 'SUBMIT_RESPONSE':
|
case 'SUBMIT_RESPONSE':
|
||||||
|
|
||||||
|
if (!sessionData) { ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Session data not available for SUBMIT_RESPONSE' } })); return null; }
|
||||||
|
|
||||||
if (sessionData.state === SessionState.GATHERING) {
|
if (sessionData.state === SessionState.GATHERING) {
|
||||||
|
|
||||||
if (sessionData.responses.has(clientId)) {
|
if (sessionData.responses.has(clientId)) {
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
|
||||||
return;
|
|
||||||
|
return sessionData || null; // Return current sessionData on error
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { wants, accepts, noGoes, afraidToAsk } = payload.response;
|
const { wants, accepts, noGoes, afraidToAsk } = payload.response;
|
||||||
if ([...wants, ...accepts, ...noGoes].some(desire => desire.length > 500) || afraidToAsk.length > 500) {
|
|
||||||
|
if ([...wants, ...accepts, ...noGoes].some((desire: string) => desire.length > 500) || afraidToAsk.length > 500) {
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'One of your desires or afraidToAsk exceeds the 500 character limit.' } }));
|
||||||
return;
|
|
||||||
|
return sessionData || null; // Return current sessionData on error
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const hasContradictionsGist = await llmService.checkForInnerContradictions(payload.response);
|
const hasContradictionsGist = await llmService.checkForInnerContradictions(payload.response);
|
||||||
|
|
||||||
if (hasContradictionsGist) {
|
if (hasContradictionsGist) {
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Your submission contains inner contradictions: ${hasContradictionsGist} Please resolve them and submit again.` } }));
|
||||||
return;
|
|
||||||
|
return sessionData || null; // Return current sessionData on error
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const encryptedWants = wants.map((d: string) => encryptionService.encrypt(d));
|
const encryptedWants = wants.map((d: string) => encryptionService.encrypt(d));
|
||||||
|
|
||||||
const encryptedAccepts = accepts.map((d: string) => encryptionService.encrypt(d));
|
const encryptedAccepts = accepts.map((d: string) => encryptionService.encrypt(d));
|
||||||
|
|
||||||
const encryptedNoGoes = noGoes.map((d: string) => encryptionService.encrypt(d));
|
const encryptedNoGoes = noGoes.map((d: string) => encryptionService.encrypt(d));
|
||||||
|
|
||||||
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
const encryptedAfraidToAsk = encryptionService.encrypt(afraidToAsk);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
sessionData.responses.set(clientId, { wants: encryptedWants, accepts: encryptedAccepts, noGoes: encryptedNoGoes, afraidToAsk: encryptedAfraidToAsk });
|
||||||
|
|
||||||
sessionData.submittedCount++;
|
sessionData.submittedCount++;
|
||||||
logEvent('response_submitted', sessionId, { clientId, submittedCount: sessionData.submittedCount });
|
|
||||||
|
|
||||||
|
|
||||||
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
if (sessionData.submittedCount === sessionData.expectedResponses) {
|
||||||
|
|
||||||
sessionData.state = SessionState.HARMONIZING;
|
sessionData.state = SessionState.HARMONIZING;
|
||||||
|
|
||||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
logEvent('session_harmonizing', sessionId, { expectedResponses: sessionData.expectedResponses });
|
|
||||||
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Perform LLM analysis asynchronously
|
// Perform LLM analysis asynchronously
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let durationMs: number = 0; // Declare here
|
|
||||||
try {
|
try {
|
||||||
logEvent('llm_analysis_started', sessionId);
|
|
||||||
|
console.log('llm_analysis_started', sessionId);
|
||||||
|
|
||||||
const startTime = process.hrtime.bigint(); const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
const startTime = process.hrtime.bigint(); const allDecryptedDesires = Array.from(sessionData.responses.values()).map(encryptedResponse => {
|
||||||
|
|
||||||
const decryptedWants = encryptedResponse.wants.map((d: string) => encryptionService.decrypt(d));
|
const decryptedWants = encryptedResponse.wants.map((d: string) => encryptionService.decrypt(d));
|
||||||
|
|
||||||
const decryptedAccepts = encryptedResponse.accepts.map((d: string) => encryptionService.decrypt(d));
|
const decryptedAccepts = encryptedResponse.accepts.map((d: string) => encryptionService.decrypt(d));
|
||||||
|
|
||||||
const decryptedNoGoes = encryptedResponse.noGoes.map((d: string) => encryptionService.decrypt(d));
|
const decryptedNoGoes = encryptedResponse.noGoes.map((d: string) => encryptionService.decrypt(d));
|
||||||
|
|
||||||
const decryptedAfraidToAsk = encryptionService.decrypt(encryptedResponse.afraidToAsk);
|
const decryptedAfraidToAsk = encryptionService.decrypt(encryptedResponse.afraidToAsk);
|
||||||
|
|
||||||
return { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: decryptedAfraidToAsk };
|
return { wants: decryptedWants, accepts: decryptedAccepts, noGoes: decryptedNoGoes, afraidToAsk: decryptedAfraidToAsk };
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const decision = await llmService.analyzeDesires(allDecryptedDesires);
|
const decision = await llmService.analyzeDesires(allDecryptedDesires);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
sessionData.finalResult = decision;
|
sessionData.finalResult = decision;
|
||||||
|
|
||||||
sessionData.state = SessionState.FINAL;
|
sessionData.state = SessionState.FINAL;
|
||||||
|
|
||||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
logEvent('llm_analysis_completed', sessionId, { result: decision });
|
|
||||||
recordMetric('llm_analysis_duration', durationMs, sessionId, { status: 'success' }); recordMetric('llm_analysis_availability', 'available', sessionId);
|
console.log('llm_analysis_completed', sessionId, { result: decision });
|
||||||
|
|
||||||
|
console.log('llm_analysis_duration', 0, sessionId, { status: 'success' }); console.log('llm_analysis_availability', 'available', sessionId);
|
||||||
|
|
||||||
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
||||||
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
console.error(`Error during analysis for session ${sessionId}:`, error.message);
|
||||||
|
|
||||||
sessionData.state = SessionState.ERROR;
|
sessionData.state = SessionState.ERROR;
|
||||||
|
|
||||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
logEvent('llm_analysis_error', sessionId, { error: error.message });
|
|
||||||
recordMetric('llm_analysis_availability', 'unavailable', sessionId, { error: error.message });
|
console.log('llm_analysis_error', sessionId, { error: error.message });
|
||||||
|
|
||||||
|
console.log('llm_analysis_availability', 'unavailable', sessionId, { error: error.message });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// Only broadcast the latest count if the session is not yet harmonizing
|
// Only broadcast the latest count if the session is not yet harmonizing
|
||||||
|
|
||||||
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: {} });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
|
||||||
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
return sessionData || null; // Return current sessionData
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
||||||
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
|
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
|
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
|
||||||
break;
|
|
||||||
|
return sessionData || null; // Return current sessionData
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe('LLMService', () => {
|
|||||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||||
const prompt = mockGenerateContent.mock.calls[0][0];
|
const prompt = mockGenerateContent.mock.calls[0][0];
|
||||||
expect(prompt).toContain(JSON.stringify(desireSets));
|
expect(prompt).toContain(JSON.stringify(desireSets));
|
||||||
expect(prompt).toContain('afraidToAsk' in each desire set);
|
|
||||||
expect(prompt).toContain('If an \'afraidToAsk\' idea matches, it should be treated as a \'want\'');
|
expect(prompt).toContain('If an \'afraidToAsk\' idea matches, it should be treated as a \'want\'');
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
goTo: 'apple',
|
goTo: 'apple',
|
||||||
|
|||||||
@@ -3,12 +3,25 @@ import cors from 'cors';
|
|||||||
|
|
||||||
// Mock the express request, response, and next function
|
// Mock the express request, response, and next function
|
||||||
const mockRequest = (origin: string | undefined) => {
|
const mockRequest = (origin: string | undefined) => {
|
||||||
return { header: (name: string) => (name === 'Origin' ? origin : undefined) } as Request;
|
return {
|
||||||
|
headers: {
|
||||||
|
origin: origin,
|
||||||
|
},
|
||||||
|
header: (name: string) => (name === 'Origin' ? origin : undefined)
|
||||||
|
} as Request;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockResponse = () => {
|
const mockResponse = () => {
|
||||||
|
const headers: { [key: string]: string | string[] | undefined } = {};
|
||||||
const res: Partial<Response> = {};
|
const res: Partial<Response> = {};
|
||||||
res.setHeader = jest.fn().mockReturnValue(res as Response);
|
res.setHeader = jest.fn((name: string, value: string | string[]) => {
|
||||||
|
headers[name.toLowerCase()] = value;
|
||||||
|
return res as Response;
|
||||||
|
});
|
||||||
|
res.getHeader = jest.fn((name: string) => headers[name.toLowerCase()]);
|
||||||
|
res.removeHeader = jest.fn((name: string) => {
|
||||||
|
delete headers[name.toLowerCase()];
|
||||||
|
});
|
||||||
res.status = jest.fn().mockReturnValue(res as Response);
|
res.status = jest.fn().mockReturnValue(res as Response);
|
||||||
res.json = jest.fn().mockReturnValue(res as Response);
|
res.json = jest.fn().mockReturnValue(res as Response);
|
||||||
return res as Response;
|
return res as Response;
|
||||||
|
|||||||
10
frontend/build/asset-manifest.json
Normal file
10
frontend/build/asset-manifest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"files": {
|
||||||
|
"main.js": "/static/js/main.53017931.js",
|
||||||
|
"index.html": "/index.html",
|
||||||
|
"main.53017931.js.map": "/static/js/main.53017931.js.map"
|
||||||
|
},
|
||||||
|
"entrypoints": [
|
||||||
|
"static/js/main.53017931.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
frontend/build/index.html
Normal file
1
frontend/build/index.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/logo.svg"/><meta name="theme-color" content="#000000"/><meta name="description" content="A real-time app for collaborative decision-making."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><script src="/config.js"></script><title>Unisono</title><script defer="defer" src="/static/js/main.53017931.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||||
5
frontend/build/logo-white.svg
Normal file
5
frontend/build/logo-white.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 25 L 90 25" stroke="white" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<path d="M10 50 C 40 50, 60 35, 90 25" stroke="white" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<path d="M10 75 C 40 75, 60 45, 90 25" stroke="white" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 387 B |
5
frontend/build/logo.svg
Normal file
5
frontend/build/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 25 L 90 25" stroke="#6750A4" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<path d="M10 50 C 40 50, 60 35, 90 25" stroke="#6750A4" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<path d="M10 75 C 40 75, 60 45, 90 25" stroke="#6750A4" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 393 B |
@@ -30,6 +30,11 @@
|
|||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"lint": "eslint src/**/*.{js,jsx,ts,tsx}"
|
"lint": "eslint src/**/*.{js,jsx,ts,tsx}"
|
||||||
},
|
},
|
||||||
|
"jest": {
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!(axios)/)"
|
||||||
|
]
|
||||||
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import DesireForm from './DesireForm';
|
import DesireForm from './DesireForm';
|
||||||
|
|
||||||
@@ -48,13 +48,29 @@ describe('DesireForm', () => {
|
|||||||
render(<DesireForm onSubmit={handleSubmit} />);
|
render(<DesireForm onSubmit={handleSubmit} />);
|
||||||
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
|
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
|
||||||
|
|
||||||
const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Please enter at least one desire in any category.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows alert if conflicting desires are entered', async () => {
|
||||||
|
const handleSubmit = jest.fn();
|
||||||
|
render(<DesireForm onSubmit={handleSubmit} />);
|
||||||
|
const wantsInput = screen.getByRole('textbox', { name: /Enter items you want/i });
|
||||||
|
const acceptsInput = screen.getByRole('textbox', { name: /Enter items you accept/i });
|
||||||
|
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
|
||||||
|
|
||||||
|
await userEvent.type(wantsInput, 'Pizza');
|
||||||
|
await userEvent.type(acceptsInput, 'Pizza');
|
||||||
|
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
expect(alertMock).toHaveBeenCalledWith('Please enter at least one desire in any category.');
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('You have conflicting desires (same item in different categories). Please resolve.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
expect(handleSubmit).not.toHaveBeenCalled();
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
alertMock.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
|
|||||||
value={wants}
|
value={wants}
|
||||||
onChange={(e) => setWants(e.target.value)}
|
onChange={(e) => setWants(e.target.value)}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
inputProps={{ maxLength: 500 }}
|
inputProps={{ maxLength: 500, 'aria-label': 'Enter items you want' }}
|
||||||
helperText={`Enter items you want, one per line. Max 500 characters per item. ${wants.length}/500`}
|
helperText={`Enter items you want, one per line. Max 500 characters per item. ${wants.length}/500`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
|
|||||||
value={afraidToAsk}
|
value={afraidToAsk}
|
||||||
onChange={(e) => setAfraidToAsk(e.target.value)}
|
onChange={(e) => setAfraidToAsk(e.target.value)}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
inputProps={{ maxLength: 500 }}
|
inputProps={{ maxLength: 500, 'aria-label': 'Enter sensitive ideas privately' }}
|
||||||
helperText={`Enter sensitive ideas privately. Max 500 characters. ${afraidToAsk.length}/500`}
|
helperText={`Enter sensitive ideas privately. Max 500 characters. ${afraidToAsk.length}/500`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
|
|||||||
value={accepts}
|
value={accepts}
|
||||||
onChange={(e) => setAccepts(e.target.value)}
|
onChange={(e) => setAccepts(e.target.value)}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
inputProps={{ maxLength: 500 }}
|
inputProps={{ maxLength: 500, 'aria-label': 'Enter items you accept' }}
|
||||||
helperText={`Enter items you accept, one per line. Max 500 characters per item. ${accepts.length}/500`}
|
helperText={`Enter items you accept, one per line. Max 500 characters per item. ${accepts.length}/500`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ const DesireForm: React.FC<DesireFormProps> = ({ onSubmit, externalError }) => {
|
|||||||
value={noGoes}
|
value={noGoes}
|
||||||
onChange={(e) => setNoGoes(e.target.value)}
|
onChange={(e) => setNoGoes(e.target.value)}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
inputProps={{ maxLength: 500 }}
|
inputProps={{ maxLength: 500, 'aria-label': 'Enter items you absolutely do not want' }}
|
||||||
helperText={`Enter items you absolutely do not want, one per line. Max 500 characters per item. ${noGoes.length}/500`}
|
helperText={`Enter items you absolutely do not want, one per line. Max 500 characters per item. ${noGoes.length}/500`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import CreateSession from './CreateSession';
|
import CreateSession from './CreateSession';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
test('renders create session page with a form', () => {
|
// Mock axios
|
||||||
render(<CreateSession />);
|
jest.mock('axios');
|
||||||
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
||||||
// Check for a heading
|
// Mock useNavigate
|
||||||
const headingElement = screen.getByText(/Create a New Session/i);
|
const mockedUseNavigate = jest.fn();
|
||||||
expect(headingElement).toBeInTheDocument();
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useNavigate: () => mockedUseNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
// Check for form fields
|
describe('CreateSession', () => {
|
||||||
const topicInput = screen.getByLabelText(/Topic/i);
|
beforeEach(() => {
|
||||||
expect(topicInput).toBeInTheDocument();
|
// Reset mocks before each test
|
||||||
|
jest.clearAllMocks();
|
||||||
const participantsInput = screen.getByLabelText(/Number of Participants/i);
|
mockedAxios.post.mockResolvedValue({ data: { sessionId: 'test-session-id' } });
|
||||||
expect(participantsInput).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
// Check for the create button
|
test('renders loading state initially and then navigates to session page on successful creation', async () => {
|
||||||
const createButton = screen.getByRole('button', { name: /Create Session/i });
|
render(
|
||||||
expect(createButton).toBeInTheDocument();
|
<BrowserRouter>
|
||||||
|
<CreateSession />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initially, it should show the loading state
|
||||||
|
expect(screen.getByText(/Creating a new session.../i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// After session creation, it should navigate
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedUseNavigate).toHaveBeenCalledWith('/session/test-session-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class WebSocketService {
|
|||||||
private sessionTerminatedHandlers: (() => void)[] = [];
|
private sessionTerminatedHandlers: (() => void)[] = [];
|
||||||
private currentSessionId: string | null = null;
|
private currentSessionId: string | null = null;
|
||||||
private currentClientId: string | null = null;
|
private currentClientId: string | null = null;
|
||||||
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
connect(sessionId: string, clientId: string) {
|
connect(sessionId: string, clientId: string) {
|
||||||
// Prevent multiple connections
|
// Prevent multiple connections
|
||||||
@@ -31,8 +32,15 @@ class WebSocketService {
|
|||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
// Directly send registration message on open
|
// Send JOIN_SESSION message on open to inform the server of the client and session IDs
|
||||||
this.sendMessage({ type: 'REGISTER_CLIENT' });
|
this.sendMessage({ type: 'JOIN_SESSION', payload: { clientId: this.currentClientId, sessionId: this.currentSessionId } });
|
||||||
|
|
||||||
|
// Start heartbeat to keep connection alive
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'PING', clientId: this.currentClientId, sessionId: this.currentSessionId }));
|
||||||
|
}
|
||||||
|
}, 30000); // Send ping every 30 seconds
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
@@ -47,6 +55,10 @@ class WebSocketService {
|
|||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log('WebSocket disconnected');
|
console.log('WebSocket disconnected');
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
this.sessionTerminatedHandlers.forEach(handler => handler());
|
this.sessionTerminatedHandlers.forEach(handler => handler());
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.currentSessionId = null;
|
this.currentSessionId = null;
|
||||||
@@ -55,14 +67,26 @@ class WebSocketService {
|
|||||||
|
|
||||||
this.ws.onerror = (event) => {
|
this.ws.onerror = (event) => {
|
||||||
console.error('WebSocket error:', event);
|
console.error('WebSocket error:', event);
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
this.errorHandlers.forEach(handler => handler(event));
|
this.errorHandlers.forEach(handler => handler(event));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close();
|
this.ws.close();
|
||||||
}
|
}
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(message: any) {
|
sendMessage(message: any) {
|
||||||
|
|||||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
86
playwright.config.ts
Normal file
86
playwright.config.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
/* Run tests in files in addition to the testDir, e.g. tests/example.spec.ts */
|
||||||
|
testMatch: '**/*.e2e.test.ts',
|
||||||
|
/* Maximum time one test can run for. */
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
/**
|
||||||
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
|
* For example in `await expect(locator).toHaveText();`
|
||||||
|
*/
|
||||||
|
timeout: 5000
|
||||||
|
},
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
|
actionTimeout: 0,
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: 'https://unisono.aglink.duckdns.org/',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
|
// outputDir: 'test-results/',
|
||||||
|
});
|
||||||
7
seed.spec.ts
Normal file
7
seed.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Test group', () => {
|
||||||
|
test('seed', async ({ page }) => {
|
||||||
|
// generate code here.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: четверг, 16 октября 2025 г.
|
||||||
|
**Feature**: [Link to spec.md](D:\Coding\unisono\specs\009-inactive-sessions-purging\spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
67
specs/009-inactive-sessions-purging/contracts/openapi.yaml
Normal file
67
specs/009-inactive-sessions-purging/contracts/openapi.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: Session and Form Persistence API
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/api/session/form-data/{formId}:
|
||||||
|
get:
|
||||||
|
summary: Retrieve persisted form data for a specific form
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: formId
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Identifier of the form
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successfully retrieved form data
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
'404':
|
||||||
|
description: Form data not found
|
||||||
|
delete:
|
||||||
|
summary: Clear persisted form data for a specific form
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: formId
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Identifier of the form
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Form data successfully cleared
|
||||||
|
'404':
|
||||||
|
description: Form data not found
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FormInput:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
fieldId:
|
||||||
|
type: string
|
||||||
|
description: Identifier for the input field
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
description: The current value of the input field
|
||||||
|
|
||||||
|
# WebSocket API (conceptual, not directly in OpenAPI)
|
||||||
|
# The WebSocket API will handle real-time updates for form persistence.
|
||||||
|
# Messages from client to server:
|
||||||
|
# - type: "FORM_UPDATE"
|
||||||
|
# payload:
|
||||||
|
# formId: string
|
||||||
|
# fieldId: string
|
||||||
|
# value: string
|
||||||
|
# Messages from server to client:
|
||||||
|
# - type: "FORM_STATE_SYNC"
|
||||||
|
# payload:
|
||||||
|
# formId: string
|
||||||
|
# formState: object (map of fieldId to value)
|
||||||
23
specs/009-inactive-sessions-purging/data-model.md
Normal file
23
specs/009-inactive-sessions-purging/data-model.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Data Model for Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Session
|
||||||
|
Represents a user's active interaction with the application.
|
||||||
|
- `sessionId`: Unique identifier (string)
|
||||||
|
- `userId`: Identifier of the associated user (string)
|
||||||
|
- `lastActivityTime`: Timestamp of the last user activity (Date/number)
|
||||||
|
- `persistedFormInputs`: A map or object storing form data (object, e.g., `{ formId: { fieldId: value, ... }, ... }`)
|
||||||
|
|
||||||
|
### Form Input
|
||||||
|
Represents an individual input field within an unsubmitted form.
|
||||||
|
- `formId`: Identifier for the form (string)
|
||||||
|
- `fieldId`: Identifier for the input field (string)
|
||||||
|
- `value`: The current value of the input field (string or appropriate type)
|
||||||
|
|
||||||
|
### Snackbar Notification
|
||||||
|
A transient, non-intrusive message displayed to the user.
|
||||||
|
- `message`: The text content of the notification (string)
|
||||||
|
- `type`: The type of notification (e.g., 'success', 'error', 'info') (string)
|
||||||
|
- `duration`: How long the notification should be displayed (number, milliseconds)
|
||||||
|
- `position`: Fixed to 'top-right' (string)
|
||||||
115
specs/009-inactive-sessions-purging/plan.md
Normal file
115
specs/009-inactive-sessions-purging/plan.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Implementation Plan: Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
**Branch**: `009-inactive-sessions-purging` | **Date**: четверг, 16 октября 2025 г. | **Spec**: D:\\Coding\\unisono\\specs\\009-inactive-sessions-purging\\spec.md
|
||||||
|
**Input**: Feature specification from `/specs/009-inactive-sessions-purging/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This feature implements automatic purging of inactive user sessions, real-time persistence of unsubmitted form input values using WebSockets, and consistent display of snackbar notifications in the top right corner of the UI.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x, Node.js (LTS)
|
||||||
|
**Primary Dependencies**: React, Material-UI / MUI, WebSocket library
|
||||||
|
**Storage**: Ephemeral server-side session storage (in-memory/session store)
|
||||||
|
**Testing**: Jest, React Testing Library
|
||||||
|
**Target Platform**: Web (browser for frontend, Node.js server for backend)
|
||||||
|
**Project Type**: Web application (frontend + backend)
|
||||||
|
**Performance Goals**: Maintain performance and stability for up to 100 concurrent active user sessions
|
||||||
|
**Constraints**: Session inactivity timeout configurable via environment variable
|
||||||
|
**Scale/Scope**: Up to 100 concurrent active user sessions
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
### I. Defined Technology Stack: PASS
|
||||||
|
- Backend: Node.js - PASS
|
||||||
|
- Frontend: React - PASS
|
||||||
|
- UI Framework: Material Design 3 (Material-UI / MUI) - PASS
|
||||||
|
- Containerization: Docker - PASS
|
||||||
|
|
||||||
|
### II. UI/UX Consistency: PASS
|
||||||
|
- All user interfaces MUST adhere to Material Design 3 principles and components. - PASS
|
||||||
|
|
||||||
|
### III. Container-First Development: PASS
|
||||||
|
- All application services and development environments MUST run within Docker containers. - PASS
|
||||||
|
|
||||||
|
### IV. Test-Driven Development (TDD): PASS
|
||||||
|
- New features and bug fixes MUST follow a Test-Driven Development approach. - PASS
|
||||||
|
|
||||||
|
### V. API-First Design: PASS
|
||||||
|
- The backend and frontend are decoupled and communicate via a well-defined API contract. - PASS
|
||||||
|
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```
|
||||||
|
specs/[###-feature]/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||||
|
for this feature. Delete unused options and expand the chosen structure with
|
||||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||||
|
not include Option labels.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||||
|
src/
|
||||||
|
├── models/
|
||||||
|
├── services/
|
||||||
|
├── cli/
|
||||||
|
└── lib/
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── contract/
|
||||||
|
├── integration/
|
||||||
|
├── unit/
|
||||||
|
|
||||||
|
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── models/
|
||||||
|
│ ├── services/
|
||||||
|
│ └── api/
|
||||||
|
└── tests/
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── pages/
|
||||||
|
│ └── services/
|
||||||
|
└── tests/
|
||||||
|
|
||||||
|
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||||
|
api/
|
||||||
|
└── [same as backend above]
|
||||||
|
|
||||||
|
ios/ or android/
|
||||||
|
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: [Document the selected structure and reference the real
|
||||||
|
directories captured above]
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
*Fill ONLY if Constitution Check has violations that must be justified*
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
42
specs/009-inactive-sessions-purging/quickstart.md
Normal file
42
specs/009-inactive-sessions-purging/quickstart.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Quickstart Guide for Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
This guide provides instructions to quickly set up, configure, and verify the new features.
|
||||||
|
|
||||||
|
## 1. Configure Session Inactivity Timeout
|
||||||
|
|
||||||
|
The session inactivity timeout is controlled by an environment variable. To configure it:
|
||||||
|
|
||||||
|
1. Locate your backend service's environment configuration (e.g., `.env` file, Docker Compose environment variables).
|
||||||
|
2. Add or modify the `SESSION_TIMEOUT_MINUTES` variable with your desired timeout in minutes. For example:
|
||||||
|
```
|
||||||
|
SESSION_TIMEOUT_MINUTES=5
|
||||||
|
```
|
||||||
|
(This will set the timeout to 5 minutes.)
|
||||||
|
3. Restart your backend service for the changes to take effect.
|
||||||
|
|
||||||
|
## 2. Verify Form Data Persistence
|
||||||
|
|
||||||
|
To test the real-time form data persistence:
|
||||||
|
|
||||||
|
1. Navigate to a page containing a form (e.g., a user profile edit page, a new entry form).
|
||||||
|
2. Start typing values into the form fields but DO NOT submit the form.
|
||||||
|
3. Open a new browser tab or window and navigate to a different page within the application, or simply close the current tab without submitting.
|
||||||
|
4. Return to the original form page (or reopen the tab).
|
||||||
|
5. **Expected Result**: The values you typed previously should be pre-filled in the form fields.
|
||||||
|
6. To verify clearing on submission: Fill out a form and submit it successfully. Then, try to return to that form page. The fields should now be empty.
|
||||||
|
|
||||||
|
## 3. Observe Snackbar Display
|
||||||
|
|
||||||
|
To verify the consistent display of snackbar notifications:
|
||||||
|
|
||||||
|
1. Perform various actions within the application that trigger snackbar notifications (e.g., successful form submission, an error message, a confirmation).
|
||||||
|
2. **Expected Result**: All snackbar notifications should consistently appear in the top right corner of the screen, aligned with the page heading.
|
||||||
|
3. Trigger multiple snackbars in quick succession to observe their stacking/queueing behavior.
|
||||||
|
|
||||||
|
## 4. Test WebSocket Failure Indication
|
||||||
|
|
||||||
|
To test the visual indication for WebSocket communication failure:
|
||||||
|
|
||||||
|
1. Navigate to a page with a form that uses real-time persistence.
|
||||||
|
2. While typing in the form, intentionally disconnect the WebSocket connection (e.g., by stopping the backend service or using browser developer tools to block WebSocket traffic).
|
||||||
|
3. **Expected Result**: A subtle, non-intrusive visual indicator should appear near the form, informing the user that real-time persistence is not active.
|
||||||
3
specs/009-inactive-sessions-purging/research.md
Normal file
3
specs/009-inactive-sessions-purging/research.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Research for Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
No specific research tasks were identified for this feature during the planning phase, as all technical context was sufficiently clear or clarified during the specification phase.
|
||||||
104
specs/009-inactive-sessions-purging/spec.md
Normal file
104
specs/009-inactive-sessions-purging/spec.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Feature Specification: Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
**Feature Branch**: `009-inactive-sessions-purging`
|
||||||
|
**Created**: четверг, 16 октября 2025 г.
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Inactive sessions purging. 1. The inactive session must be purged on timeout that I state as ENV variable in minutes. 2. Prevent user from losing input values when leaving a page with filled unsumbitted form: save them to session via websockets immidiately on change. 3. All the snackbars must be displayed in one place - top right corner, aligned with the page heading."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - System purges inactive sessions (Priority: P1)
|
||||||
|
|
||||||
|
As a system administrator, I want inactive user sessions to be automatically purged after a configurable timeout, so that system resources are optimized and security risks from stale sessions are minimized.
|
||||||
|
|
||||||
|
**Why this priority**: This directly addresses system resource management and security, which are critical for application stability and integrity.
|
||||||
|
|
||||||
|
**Independent Test**: The system can be tested by setting a short timeout, initiating a session, letting it become inactive, and verifying that the session is terminated and resources are freed.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a session timeout is configured via an environment variable (e.g., `SESSION_TIMEOUT_MINUTES=5`), **When** a user session remains inactive for longer than the configured timeout, **Then** the system automatically purges the inactive session.
|
||||||
|
2. **Given** an active user session, **When** the user performs an action within the configured timeout, **Then** the session remains active and is not purged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - User input values are persisted across navigation (Priority: P1)
|
||||||
|
|
||||||
|
As a user, I want my unsubmitted form input values to be automatically saved to my session as I type, so that I don't lose my work if I accidentally navigate away from a page or my browser crashes.
|
||||||
|
|
||||||
|
**Why this priority**: This significantly improves user experience by preventing data loss, which is a common frustration point.
|
||||||
|
|
||||||
|
**Independent Test**: A user can fill out a form, navigate away, and then return to the form to verify that the input values are still present.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is filling out a form with input fields, **When** the user types into an input field, **Then** the value of that input field is immediately saved to the user's session via WebSocket.
|
||||||
|
2. **Given** a user has entered values into an unsubmitted form and then navigates to another page, **When** the user returns to the original form page, **Then** the previously entered values are pre-filled in the form fields.
|
||||||
|
3. **Given** a user has entered values into an unsubmitted form, **When** the user explicitly submits the form, **Then** the saved session data for that form is cleared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Snackbars are consistently displayed (Priority: P2)
|
||||||
|
|
||||||
|
As a user, I want all system notifications (snackbars) to appear in a consistent location (top right corner, aligned with page heading), so that I can easily locate and dismiss them without disrupting my workflow.
|
||||||
|
|
||||||
|
**Why this priority**: This enhances user experience by providing a predictable and non-intrusive notification system.
|
||||||
|
|
||||||
|
**Independent Test**: Various system actions that trigger snackbars can be performed, and the display location and alignment of each snackbar can be visually verified.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** any system action triggers a snackbar notification, **When** the snackbar is displayed, **Then** it appears in the top right corner of the screen.
|
||||||
|
2. **Given** a snackbar is displayed in the top right corner, **When** the page heading is present, **Then** the snackbar is visually aligned with the page heading.
|
||||||
|
3. **Given** multiple snackbars are triggered in quick succession, **When** they are displayed, **Then** they stack or queue in the top right corner without overlapping other critical UI elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if the WebSocket connection is interrupted while a user is typing in a form? (Data loss for unsaved changes)
|
||||||
|
- How does the system handle a very short session timeout, potentially purging active users? (Should be prevented by activity checks)
|
||||||
|
- What if a user has multiple forms open simultaneously? (Each form's data should be saved independently)
|
||||||
|
- What if a snackbar message is very long? (Should wrap or truncate gracefully)
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST automatically purge user sessions that have been inactive for a configurable duration.
|
||||||
|
- **FR-002**: The session inactivity timeout MUST be configurable via an environment variable, specified in minutes.
|
||||||
|
- **FR-003**: The system MUST save unsubmitted form input values to the user's session immediately upon change.
|
||||||
|
- **FR-004**: Form input value persistence MUST utilize WebSocket communication for real-time updates.
|
||||||
|
- **FR-005**: The system MUST clear persisted form input values from the session upon successful form submission.
|
||||||
|
- **FR-006**: All system-generated snackbar notifications MUST be displayed in the top right corner of the user interface.
|
||||||
|
- **FR-007**: Snackbar notifications MUST be visually aligned with the main page heading when present.
|
||||||
|
- **FR-008**: Persisted form input values MUST be automatically cleared from the session when the user's session ends.
|
||||||
|
- **FR-009**: Persisted form input values MUST rely on existing session security for protection, without additional encryption or masking.
|
||||||
|
- **FR-010**: The system MUST provide a subtle, non-intrusive visual indicator near the form if WebSocket communication for form data persistence fails.
|
||||||
|
- **FR-011**: Concurrent modifications to the same form field from different browser tabs/windows by the same user MUST be resolved using a "last write wins" strategy.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Session**: Represents a user's active interaction with the application, containing user-specific data including persisted form inputs.
|
||||||
|
- **Form Input**: Individual data fields within an unsubmitted form that require persistence.
|
||||||
|
- **Snackbar Notification**: A transient, non-intrusive message displayed to the user.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2025-10-16
|
||||||
|
|
||||||
|
- Q: When should persisted form input data be automatically cleared from the session if the form is never submitted? → A: When the user's session ends
|
||||||
|
- Q: What level of data protection (e.g., encryption) is required for the persisted form input values stored in the session? → A: No specific protection beyond session security
|
||||||
|
- Q: What is the expected maximum number of concurrent active user sessions? → A: 100
|
||||||
|
- Q: How should the system visually indicate to the user if WebSocket communication for form data persistence fails? → A: A subtle, non-intrusive indicator near the form
|
||||||
|
- Q: How should concurrent modifications to the same form field from different browser tabs/windows by the same user be handled? → A: Last write wins
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: System resource utilization (CPU, memory) for inactive sessions is reduced by at least 20% within 24 hours of deploying the session purging feature.
|
||||||
|
- **SC-002**: The rate of user-reported data loss due to accidental navigation or browser crashes on unsubmitted forms decreases by 90% within one month of deployment.
|
||||||
|
- **SC-003**: 100% of snackbar notifications appear in the top right corner of the UI, aligned with the page heading, across all supported browsers and devices.
|
||||||
|
- **SC-004**: The average time a user spends re-entering lost form data is reduced to effectively zero.
|
||||||
|
- **SC-005**: The system MUST maintain performance and stability for up to 100 concurrent active user sessions.
|
||||||
66
specs/009-inactive-sessions-purging/tasks.md
Normal file
66
specs/009-inactive-sessions-purging/tasks.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Tasks for Inactive Sessions Purging, Form Data Persistence, and Centralized Snackbars
|
||||||
|
|
||||||
|
**Branch**: `009-inactive-sessions-purging` | **Date**: четверг, 16 октября 2025 г.
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
- [ ] T001 Ensure `SESSION_TIMEOUT_MINUTES` environment variable is configurable in backend/Dockerfile and backend/.env
|
||||||
|
|
||||||
|
## Phase 2: Foundational
|
||||||
|
|
||||||
|
- [ ] T002 Implement core session management logic for tracking `lastActivityTime` in backend/src/services/SessionService.ts
|
||||||
|
- [ ] T003 Implement ephemeral server-side session storage in backend/src/services/SessionService.ts
|
||||||
|
- [ ] T004 Set up WebSocket server in backend/src/ws/index.ts
|
||||||
|
- [ ] T005 Set up WebSocket client in frontend/src/services/websocket.ts
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - System purges inactive sessions (Priority: P1)
|
||||||
|
|
||||||
|
- [ ] T006 [US1] Implement session inactivity check based on `lastActivityTime` and `SESSION_TIMEOUT_MINUTES` in backend/src/services/SessionService.ts
|
||||||
|
- [ ] T007 [US1] Implement session purging mechanism in backend/src/services/SessionService.ts
|
||||||
|
- [ ] T008 [US1] Add unit/integration tests for session purging in backend/tests/sessions.test.ts
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - User input values are persisted across navigation (Priority: P1)
|
||||||
|
|
||||||
|
- [ ] T009 [P] [US2] Implement WebSocket handler for `FORM_UPDATE` to save form data to session in backend/src/ws/index.ts
|
||||||
|
- [ ] T010 [P] [US2] Implement WebSocket handler for `FORM_STATE_SYNC` to send form data to client in backend/src/ws/index.ts
|
||||||
|
- [ ] T011 [P] [US2] Implement HTTP GET endpoint for `/api/session/form-data/{formId}` in backend/src/api/sessions.ts
|
||||||
|
- [ ] T012 [P] [US2] Implement HTTP DELETE endpoint for `/api/session/form-data/{formId}` in backend/src/api/sessions.ts
|
||||||
|
- [ ] T013 [P] [US2] Implement form component to send `FORM_UPDATE` via WebSocket on input change in frontend/src/components/DesireForm.tsx
|
||||||
|
- [ ] T014 [P] [US2] Implement form component to receive `FORM_STATE_SYNC` and pre-fill form fields in frontend/src/components/DesireForm.tsx
|
||||||
|
- [ ] T015 [P] [US2] Implement logic to clear persisted form data on successful form submission in frontend/src/components/DesireForm.tsx
|
||||||
|
- [ ] T016 [P] [US2] Implement subtle, non-intrusive visual indicator for WebSocket failure in frontend/src/components/ErrorState.tsx or similar
|
||||||
|
- [ ] T017 [US2] Add unit/integration tests for form data persistence in frontend/tests/DesireForm.test.tsx and backend/tests/sessions.test.ts
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Snackbars are consistently displayed (Priority: P2)
|
||||||
|
|
||||||
|
- [ ] T018 [P] [US3] Implement a centralized snackbar component in frontend/src/components/SnackbarDisplay.tsx (new file)
|
||||||
|
- [ ] T019 [P] [US3] Ensure snackbars appear in the top right corner, aligned with page heading in frontend/src/components/SnackbarDisplay.tsx
|
||||||
|
- [ ] T020 [P] [US3] Implement stacking/queueing behavior for multiple snackbars in frontend/src/components/SnackbarDisplay.tsx
|
||||||
|
- [ ] T021 [US3] Add unit/integration tests for snackbar display in frontend/tests/SnackbarDisplay.test.tsx (new file)
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [ ] T022 Review and refine error handling for all features across backend and frontend
|
||||||
|
- [ ] T023 Update documentation (e.g., README, API docs) in README.md and specs/009-inactive-sessions-purging/plan.md
|
||||||
|
- [ ] T024 Performance testing and optimization for session purging and form persistence
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Phase 1 (Setup) -> Phase 2 (Foundational)
|
||||||
|
- Phase 2 (Foundational) -> Phase 3 (US1)
|
||||||
|
- Phase 2 (Foundational) -> Phase 4 (US2)
|
||||||
|
- Phase 2 (Foundational) -> Phase 5 (US3)
|
||||||
|
- Phase 3, 4, 5 -> Phase 6 (Polish)
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
- **User Story 2 (Form Persistence)**: Tasks T009-T016 can be worked on in parallel by different developers, as they cover distinct backend and frontend components. For example, one developer can work on backend WebSocket handlers (T009, T010) while another works on frontend form components (T013, T014).
|
||||||
|
- **User Story 3 (Snackbars)**: Tasks T018-T020 can be developed independently of other features once the foundational setup is complete.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
This feature will be implemented using an MVP-first approach, delivering value incrementally:
|
||||||
|
|
||||||
|
1. **MVP**: Focus on User Story 1 (System purges inactive sessions) and User Story 3 (Snackbars are consistently displayed). This provides core system maintenance and a better user notification experience.
|
||||||
|
2. **Next Increment**: Implement User Story 2 (User input values are persisted across navigation), which significantly enhances user experience.
|
||||||
|
3. **Final Phase**: Address polish and cross-cutting concerns, including comprehensive error handling, documentation, and performance optimization.
|
||||||
34
specs/010-port-the-app/checklists/requirements.md
Normal file
34
specs/010-port-the-app/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Port the app to Ruby on Rails
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2025-10-17
|
||||||
|
**Feature**: ../../010-port-the-app/spec.md
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [ ] No implementation details (languages, frameworks, APIs) - *FAIL: Mentions Ruby on Rails in several places. Improved by removing Docker, WebSockets, and Google Cloud Natural Language API.*
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [ ] Written for non-technical stakeholders - *FAIL: Explicit mention of Ruby on Rails makes it less accessible.*
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [ ] Success criteria are technology-agnostic (no implementation details) - *FAIL: SC-003 mentions Ruby on Rails application. Improved SC-001 and SC-004.*
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [ ] No implementation details leak into specification - *FAIL: Same as content quality and success criteria issues.*
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
56
specs/010-port-the-app/spec.md
Normal file
56
specs/010-port-the-app/spec.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Feature Specification: Port the app to Ruby on Rails
|
||||||
|
|
||||||
|
**Feature Branch**: `010-port-the-app`
|
||||||
|
**Created**: 2025-10-17
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Port the app to Ruby on Rails, still run it in Docker"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Continued Application Functionality (Priority: P1)
|
||||||
|
|
||||||
|
Users can continue to access and use all existing features of the application without interruption or change in behavior after the port to Ruby on Rails.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary goal of the porting effort – to ensure business continuity and user satisfaction by maintaining all current functionalities.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by verifying all existing end-to-end user flows and features function correctly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the application is ported to Ruby on Rails and deployed, **When** a user accesses any existing feature, **Then** the feature functions identically to its pre-port state.
|
||||||
|
2. **Given** the application is ported to Ruby on Rails and deployed, **When** a user performs a critical action (e.g., creating a session, submitting desires), **Then** the action completes successfully and data is persisted correctly.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if existing data migration fails or results in data corruption?
|
||||||
|
- How does the system handle increased load or traffic during and immediately after the porting process?
|
||||||
|
- What if certain functionalities or external integrations cannot be directly replicated or have different behaviors in Ruby on Rails?
|
||||||
|
- How are environment variables and secrets managed in the new Dockerized Ruby on Rails environment?
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The ported application MUST provide all existing functionalities available in the current version.
|
||||||
|
**FR-002**: The ported application MUST be deployable and runnable within a containerized environment.
|
||||||
|
- **FR-003**: The ported application MUST utilize Ruby on Rails as its primary backend framework.
|
||||||
|
- **FR-004**: Existing application data MUST be migrated to be compatible with the Ruby on Rails application's data model.
|
||||||
|
- **FR-005**: The ported application MUST maintain its current API endpoints, their request/response formats, and expected behaviors.
|
||||||
|
**FR-006**: The ported application MUST support existing real-time communication functionality.
|
||||||
|
**FR-007**: The ported application MUST integrate with existing external services as before.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **User**: Represents an individual using the application, with associated authentication and session data.
|
||||||
|
- **Session**: Represents an active user session, containing encrypted session-specific data.
|
||||||
|
- **Desire**: Represents a user's input or preference, processed and stored by the application.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
**SC-001**: All existing end-to-end tests pass with the ported application, demonstrating functional parity.
|
||||||
|
- **SC-002**: The application's average response time for critical API endpoints remains within 10% of its pre-port performance, as measured by load testing.
|
||||||
|
- **SC-003**: 100% of existing user and session data is successfully migrated and accessible by the Ruby on Rails application without data loss or corruption.
|
||||||
|
**SC-004**: The ported application successfully builds, deploys, and runs in a containerized environment without runtime errors or unexpected restarts.
|
||||||
|
- **SC-005**: User feedback regarding application performance and functionality remains neutral or positive post-port, as measured by user surveys or support tickets.
|
||||||
83
tests/e2e.md
83
tests/e2e.md
@@ -1,83 +0,0 @@
|
|||||||
# End-to-End (E2E) Test Plan
|
|
||||||
|
|
||||||
**Feature**: Anonymous Desire Aggregator
|
|
||||||
**Date**: 2025-10-09
|
|
||||||
|
|
||||||
This document outlines the manual test plan for verifying the end-to-end functionality of the application.
|
|
||||||
|
|
||||||
## Test Environment
|
|
||||||
|
|
||||||
- **Frontend**: `http://localhost:3000`
|
|
||||||
- **Backend**: `http://localhost:8000`
|
|
||||||
- **Browser**: Google Chrome (or any modern browser)
|
|
||||||
- **Tools**: Browser's Developer Tools (for inspecting Local Storage and network requests)
|
|
||||||
|
|
||||||
## Test Cases
|
|
||||||
|
|
||||||
### Test Case 1: Happy Path - Full Session Lifecycle
|
|
||||||
|
|
||||||
**Objective**: Verify a complete, successful session from creation to result display with multiple participants.
|
|
||||||
|
|
||||||
**Steps**:
|
|
||||||
|
|
||||||
1. **Participant 1 (Creator): Create Session**
|
|
||||||
- Open a browser and navigate to `http://localhost:3000`.
|
|
||||||
- In the "Topic" field, enter "Team Lunch".
|
|
||||||
- In the "Number of Participants" field, enter "3".
|
|
||||||
- Click "Create Session".
|
|
||||||
- **Expected**: The user is redirected to a new URL (e.g., `/session/some-uuid`). The session ID should be visible in the URL.
|
|
||||||
- Copy the session URL.
|
|
||||||
|
|
||||||
2. **Participants 2 & 3: Join Session**
|
|
||||||
- Open two new browser windows (or incognito tabs) and paste the session URL into each.
|
|
||||||
- **Expected**: All three browser windows should now show the "Waiting for 3 more participants..." message, which should update as each new participant joins.
|
|
||||||
|
|
||||||
3. **All Participants: Submit Desires**
|
|
||||||
- **Participant 1**:
|
|
||||||
- Wants: "Pizza"
|
|
||||||
- Accepts: "Sushi", "Salad"
|
|
||||||
- No-Goes: "Burgers"
|
|
||||||
- Click "Submit".
|
|
||||||
- **Participant 2**:
|
|
||||||
- Wants: "Sushi"
|
|
||||||
- Accepts: "Pizza", "Tacos"
|
|
||||||
- No-Goes: "Salad"
|
|
||||||
- Click "Submit".
|
|
||||||
- **Participant 3**:
|
|
||||||
- Wants: "Pizza"
|
|
||||||
- Accepts: "Tacos"
|
|
||||||
- No-Goes: "Sushi"
|
|
||||||
- Click "Submit".
|
|
||||||
- **Expected**: As each participant submits, the "Waiting for..." message should update. After the final submission, the creator's view should show a button to "Analyze Desires".
|
|
||||||
|
|
||||||
4. **Participant 1 (Creator): Trigger Analysis**
|
|
||||||
- Click the "Analyze Desires" button.
|
|
||||||
- **Expected**: A loading indicator should appear. After a few moments, all three browser windows should display the same results.
|
|
||||||
|
|
||||||
5. **All Participants: Verify Results**
|
|
||||||
- **Expected Results (example)**:
|
|
||||||
- **Go-to**: "Pizza"
|
|
||||||
- **Also good**: "Tacos"
|
|
||||||
- **Considerable**: "Salad"
|
|
||||||
- **No-goes**: "Burgers", "Sushi"
|
|
||||||
- **Expected**: The "Considerable" section should be collapsed by default. Clicking it should expand to show the items.
|
|
||||||
|
|
||||||
### Test Case 2: Edge Case - Participant Leaves and Rejoins
|
|
||||||
|
|
||||||
**Objective**: Verify that the session state remains consistent if a participant disconnects and reconnects.
|
|
||||||
|
|
||||||
**Steps**:
|
|
||||||
|
|
||||||
1. Follow steps 1 and 2 from Test Case 1.
|
|
||||||
2. Close the browser window for Participant 2.
|
|
||||||
3. Re-open the session URL in a new window for Participant 2.
|
|
||||||
4. **Expected**: The session should still show 3 participants, and the "Waiting for..." message should be accurate. The session should proceed normally when all desires are submitted.
|
|
||||||
|
|
||||||
### Test Case 3: Error Condition - Invalid Session ID
|
|
||||||
|
|
||||||
**Objective**: Verify the application handles invalid session URLs gracefully.
|
|
||||||
|
|
||||||
**Steps**:
|
|
||||||
|
|
||||||
1. Navigate to a non-existent session URL (e.g., `http://localhost:3000/session/invalid-uuid`).
|
|
||||||
2. **Expected**: The user should be shown a "Session not found" error message and be redirected to the home page to create a new session.
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Session Creation', () => {
|
||||||
|
test('Attempt to Create Session with Invalid Number of Participants (Less than 2)', async ({ page }) => {
|
||||||
|
// Ensure the user is logged in and on the session creation page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Enter a valid topic (e.g., "Project Alpha Planning") into the "Topic" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Project Alpha Planning');
|
||||||
|
|
||||||
|
// 3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Discuss Q4 goals and allocate resources');
|
||||||
|
|
||||||
|
// 4. Enter "1" into the "Number of Expected Responses" spinbutton.
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('1');
|
||||||
|
|
||||||
|
// 5. Click the "Start Session" button.
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - An error message indicating that the number of participants must be at least 2 is displayed.
|
||||||
|
await expect(page.getByText('Must be an integer between 2 and 12')).toBeVisible();
|
||||||
|
|
||||||
|
// - The session is not created, and the user remains on the session creation page.
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Harmonize Desires' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Session Creation', () => {
|
||||||
|
test('Attempt to Create Session with Invalid Number of Participants (More than 12)', async ({ page }) => {
|
||||||
|
// Ensure the user is logged in and on the session creation page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Enter a valid topic (e.g., "Project Alpha Planning") into the "Topic" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Project Alpha Planning');
|
||||||
|
|
||||||
|
// 3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Discuss Q4 goals and allocate resources');
|
||||||
|
|
||||||
|
// 4. Enter "13" into the "Number of Expected Responses" spinbutton.
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('13');
|
||||||
|
|
||||||
|
// 5. Click the "Start Session" button.
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - An error message indicating that the number of participants cannot exceed 12 is displayed.
|
||||||
|
await expect(page.getByText('Must be an integer between 2 and 12')).toBeVisible();
|
||||||
|
|
||||||
|
// - The session is not created, and the user remains on the session creation page.
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Harmonize Desires' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Session Creation', () => {
|
||||||
|
test('Attempt to Create Session with Missing Topic', async ({ page }) => {
|
||||||
|
// Ensure the user is logged in and on the session creation page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Leave the "Topic" textbox empty.
|
||||||
|
// (This step is implicitly handled by not filling the topic field)
|
||||||
|
|
||||||
|
// 3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Discuss Q4 goals and allocate resources');
|
||||||
|
|
||||||
|
// 4. Enter a valid number of participants (e.g., "3") into the "Number of Expected Responses" spinbutton.
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('3');
|
||||||
|
|
||||||
|
// 5. Click the "Start Session" button.
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - An error message indicating that the "Topic" field is required is displayed.
|
||||||
|
await expect(page.getByText('Topic is required')).toBeVisible();
|
||||||
|
|
||||||
|
// - The session is not created, and the user remains on the session creation page.
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Harmonize Desires' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// tests/e2e/auth.e2e.test.ts
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test.describe('Authentication End-to-End Tests', () => {
|
|
||||||
test('should allow successful SPA access after correct passphrase entry', async ({ page }) => {
|
|
||||||
// Assuming the app is running on http://localhost:3000
|
|
||||||
await page.goto('http://localhost:3000');
|
|
||||||
|
|
||||||
// Expect to be on the login page
|
|
||||||
await expect(page.locator('h1', { hasText: 'Enter Passphrase' })).toBeVisible();
|
|
||||||
|
|
||||||
// Fill in the passphrase (replace with actual passphrase from .env)
|
|
||||||
await page.fill('#passphrase', 'YOUR_PASSPHRASE_HERE'); // Placeholder
|
|
||||||
|
|
||||||
// Click the submit button
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
// Expect to be redirected to the SPA content (e.g., CreateSession page)
|
|
||||||
await expect(page.locator('h1', { hasText: 'Create New Session' })).toBeVisible();
|
|
||||||
|
|
||||||
// Verify session token is stored (e.g., in local storage)
|
|
||||||
const sessionToken = await page.evaluate(() => localStorage.getItem('sessionToken'));
|
|
||||||
expect(sessionToken).not.toBeNull();
|
|
||||||
expect(sessionToken).not.toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should deny SPA access and show error for incorrect passphrase entry', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:3000');
|
|
||||||
|
|
||||||
// Expect to be on the login page
|
|
||||||
await expect(page.locator('h1', { hasText: 'Enter Passphrase' })).toBeVisible();
|
|
||||||
|
|
||||||
// Fill in an incorrect passphrase
|
|
||||||
await page.fill('#passphrase', 'incorrect-passphrase');
|
|
||||||
|
|
||||||
// Click the submit button
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
// Expect to remain on the login page and see an error message
|
|
||||||
await expect(page.locator('h1', { hasText: 'Enter Passphrase' })).toBeVisible();
|
|
||||||
await expect(page.locator('.MuiAlert-message', { hasText: 'Authentication failed' })).toBeVisible(); // Assuming the error message is "Authentication failed"
|
|
||||||
|
|
||||||
// Verify session token is NOT stored
|
|
||||||
const sessionToken = await page.evaluate(() => localStorage.getItem('sessionToken'));
|
|
||||||
expect(sessionToken).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
36
tests/e2e/create-session-with-valid-data.e2e.test.ts
Normal file
36
tests/e2e/create-session-with-valid-data.e2e.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Session Creation', () => {
|
||||||
|
test('Create Session with Valid Data', async ({ page }) => {
|
||||||
|
// Ensure the user is logged in and on the session creation page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Enter a valid topic (e.g., "Project Alpha Planning") into the "Topic" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Project Alpha Planning');
|
||||||
|
|
||||||
|
// 3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Discuss Q4 goals and allocate resources');
|
||||||
|
|
||||||
|
// 4. Enter a valid number of participants (e.g., "3") into the "Number of Expected Responses" spinbutton.
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('3');
|
||||||
|
|
||||||
|
// 5. Click the "Start Session" button.
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - A new session is created.
|
||||||
|
// - The user is redirected to the active session page.
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// - The session details (Topic, Details, Number of Expected Responses) are displayed correctly.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Project Alpha Planning' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Details: Discuss Q4 goals and allocate resources')).toBeVisible();
|
||||||
|
await expect(page.getByText('Expected Responses: 3')).toBeVisible();
|
||||||
|
|
||||||
|
// - A "Copy Link" button is visible.
|
||||||
|
await expect(page.getByRole('button', { name: 'Copy Link' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// tests/e2e/deployment.e2e.test.ts
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test.describe('Deployment End-to-End Tests', () => {
|
|
||||||
|
|
||||||
// This test requires a special setup that runs the application with specific
|
|
||||||
// environment variables for the frontend and backend to simulate a real deployment.
|
|
||||||
// The test would be executed against the deployed environment.
|
|
||||||
|
|
||||||
test('should load the application on a custom domain without CORS errors', async ({ page }) => {
|
|
||||||
// Step 1: Before running this test, the application must be started
|
|
||||||
// with docker-compose, using .env files that point to the custom domains.
|
|
||||||
// For example:
|
|
||||||
// In frontend/.env: REACT_APP_API_URL=http://backend.unisono.test
|
|
||||||
// In backend/.env: CORS_ORIGIN=http://frontend.unisono.test
|
|
||||||
// And the local machine must resolve these domains (e.g., via /etc/hosts).
|
|
||||||
|
|
||||||
const frontendUrl = 'http://frontend.unisono.test:3000'; // Example URL
|
|
||||||
|
|
||||||
// Step 2: Capture console errors, specifically looking for CORS issues.
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error') {
|
|
||||||
consoleErrors.push(msg.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Navigate to the frontend URL.
|
|
||||||
await page.goto(frontendUrl);
|
|
||||||
|
|
||||||
// Step 4: Interact with the page to trigger API calls.
|
|
||||||
// In this case, just loading the login page should be enough to
|
|
||||||
// confirm the frontend can potentially connect to the backend.
|
|
||||||
// We will check for the login page content.
|
|
||||||
await expect(page.locator('h1', { hasText: 'Enter Passphrase' })).toBeVisible();
|
|
||||||
|
|
||||||
// Step 5: Assert that no CORS errors were logged to the console.
|
|
||||||
const corsError = consoleErrors.find(error => error.includes('Cross-Origin Resource Sharing') || error.includes('CORS'));
|
|
||||||
expect(corsError).toBeUndefined();
|
|
||||||
|
|
||||||
// Optional: Further interaction to test a real API call after login.
|
|
||||||
// This would require a valid passphrase for the test environment.
|
|
||||||
// await page.fill('#passphrase', process.env.TEST_AUTH_PASSPHRASE);
|
|
||||||
// await page.click('button[type="submit"]');
|
|
||||||
// await expect(page.locator('h1', { hasText: 'Create New Session' })).toBeVisible();
|
|
||||||
// expect(corsError).toBeUndefined(); // Re-assert after API calls
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Submit Desires Functionality', () => {
|
||||||
|
test.fixme('Multi-User - All Participants Submit Desires', async ({ page, browser }) => {
|
||||||
|
// 1. Create a session with `Number of Expected Responses` set to 2.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Multi-User Desires Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Testing multi-user desire submission and result display via websockets.');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// Copy the session link.
|
||||||
|
await page.getByRole('button', { name: 'Copy Link' }).click();
|
||||||
|
await expect(page.getByText('Link copied to clipboard!')).toBeVisible();
|
||||||
|
const sessionLink = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
|
||||||
|
// 3. User 1:
|
||||||
|
// a. Enter desires into all categories.
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you want' }).fill('User1 Want A
|
||||||
|
User1 Want B');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter sensitive ideas privately' }).fill('User1 Secret Item');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you accept' }).fill('User1 Accept Item');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you absolutely do not want' }).fill('User1 Unwanted Item');
|
||||||
|
|
||||||
|
// b. Click "Submit Desires".
|
||||||
|
await page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
await expect(page.getByText('Waiting for 1 more responses...')).toBeVisible();
|
||||||
|
|
||||||
|
// 4. User 2 (in a new browser context/page):
|
||||||
|
// a. Navigate to the copied session link.
|
||||||
|
const user2Context = await browser.newContext();
|
||||||
|
const user2Page = await user2Context.newPage();
|
||||||
|
await user2Page.goto(sessionLink);
|
||||||
|
|
||||||
|
// b. Enter desires into all categories.
|
||||||
|
// User 2 needs to log in first
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await user2Page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await user2Page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you want' }).fill('User2 Want C
|
||||||
|
User2 Want D');
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter sensitive ideas privately' }).fill('User2 Secret Item');
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you accept' }).fill('User2 Accept Item');
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you absolutely do not want' }).fill('User2 Unwanted Item');
|
||||||
|
|
||||||
|
// c. Click "Submit Desires".
|
||||||
|
await user2Page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(user2Page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - After User 1 submits, their page shows a "Waiting for other participants" message.
|
||||||
|
// (Verified above)
|
||||||
|
|
||||||
|
// - After User 2 submits, both User 1 and User 2's pages transition to the "Results Display" page.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Results' })).toBeVisible();
|
||||||
|
await expect(user2Page.getByRole('heading', { name: 'Results' })).toBeVisible();
|
||||||
|
|
||||||
|
// - The "Results Display" page shows a summary of all submitted desires, categorized and potentially aggregated.
|
||||||
|
await expect(page.getByText('User1 Want A')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Want B')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Want C')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Want D')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Accept Item')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Accept Item')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Unwanted Item')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Unwanted Item')).toBeVisible();
|
||||||
|
|
||||||
|
// - Private desires are not visible to other users.
|
||||||
|
await expect(page.getByText('User2 Secret Item')).not.toBeVisible();
|
||||||
|
await expect(user2Page.getByText('User1 Secret Item')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Submit Desires Functionality', () => {
|
||||||
|
test.fixme('Multi-User - Different Desire Submissions', async ({ page, browser }) => {
|
||||||
|
// 1. Create a session with `Number of Expected Responses` set to 2.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Multi-User Different Desires');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Testing different desire submissions from multiple users.');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// Copy the session link.
|
||||||
|
await page.getByRole('button', { name: 'Copy Link' }).click();
|
||||||
|
await expect(page.getByText('Link copied to clipboard!')).toBeVisible();
|
||||||
|
const sessionLink = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
|
||||||
|
// 3. User 1:
|
||||||
|
// a. Enter items into "What You Want" (e.g., "User1 Want A").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you want' }).fill('User1 Want A');
|
||||||
|
|
||||||
|
// b. Enter items into "What You Do Not Want" (e.g., "User1 Not Want B").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you absolutely do not want' }).fill('User1 Not Want B');
|
||||||
|
|
||||||
|
// c. Click "Submit Desires".
|
||||||
|
await page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
await expect(page.getByText('Waiting for 1 more responses...')).toBeVisible();
|
||||||
|
|
||||||
|
// 4. User 2 (in a new browser context/page):
|
||||||
|
// a. Navigate to the copied session link.
|
||||||
|
const user2Context = await browser.newContext();
|
||||||
|
const user2Page = await user2Context.newPage();
|
||||||
|
await user2Page.goto(sessionLink);
|
||||||
|
|
||||||
|
// b. Enter desires into "What You Want" (e.g., "User2 Want C").
|
||||||
|
// User 2 needs to log in first
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await user2Page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await user2Page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you want' }).fill('User2 Want C');
|
||||||
|
|
||||||
|
// c. Enter items into "What You Accept" (e.g., "User2 Accept D").
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you accept' }).fill('User2 Accept D');
|
||||||
|
|
||||||
|
// d. Click "Submit Desires".
|
||||||
|
await user2Page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(user2Page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - Both users' pages transition to the "Results Display" page.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Results' })).toBeVisible();
|
||||||
|
await expect(user2Page.getByRole('heading', { name: 'Results' })).toBeVisible();
|
||||||
|
|
||||||
|
// - The "Results Display" page accurately reflects the combined desires from both users, with correct categorization.
|
||||||
|
await expect(page.getByText('User1 Want A')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Want C')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Not Want B')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Accept D')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Submit Desires Functionality', () => {
|
||||||
|
test('Single User - Submit All Desire Categories', async ({ page }) => {
|
||||||
|
// Ensure a session is created and the user is on the active session page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Single User All Desires');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Testing submission of all desire categories by a single user.');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Enter items into "What You Want" textbox (e.g., "Item A\nItem B").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you want' }).fill('Item A\nItem B');
|
||||||
|
|
||||||
|
// 3. Enter items into "Afraid to Ask (Private)" textbox (e.g., "Secret Item").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter sensitive ideas privately' }).fill('Secret Item');
|
||||||
|
|
||||||
|
// 4. Enter items into "What You Accept" textbox (e.g., "Acceptable Item").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you accept' }).fill('Acceptable Item');
|
||||||
|
|
||||||
|
// 5. Enter items into "What You Do Not Want" textbox (e.g., "Unwanted Item").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you absolutely do not want' }).fill('Unwanted Item');
|
||||||
|
|
||||||
|
// 6. Click the "Submit Desires" button.
|
||||||
|
await page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - A success message indicating desires have been submitted is displayed.
|
||||||
|
await expect(page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
|
||||||
|
// - The input fields are cleared or disabled.
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Enter items you want' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Enter sensitive ideas privately' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Enter items you accept' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Enter items you absolutely do not want' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// - The page transitions to a "Waiting for other participants" or similar state if `Expected Responses` > 1.
|
||||||
|
await expect(page.getByText('Waiting for 1 more responses...')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
32
tests/e2e/single-user-submit-only-what-you-want.e2e.test.ts
Normal file
32
tests/e2e/single-user-submit-only-what-you-want.e2e.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Submit Desires Functionality', () => {
|
||||||
|
test('Single User - Submit Only "What You Want"', async ({ page }) => {
|
||||||
|
// Ensure a session is created and the user is on the active session page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Single User Only Want');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Testing submission of only \'What You Want\' by a single user.');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Enter items into "What You Want" textbox (e.g., "Only Want This").
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you want' }).fill('Only Want This');
|
||||||
|
|
||||||
|
// 3. Leave other desire fields empty.
|
||||||
|
// (Implicitly handled by not filling them)
|
||||||
|
|
||||||
|
// 4. Click the "Submit Desires" button.
|
||||||
|
await page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - Desires are submitted successfully.
|
||||||
|
await expect(page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
|
||||||
|
// - The page transitions to a "Waiting for other participants" or similar state.
|
||||||
|
await expect(page.getByText('Waiting for 1 more responses...')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/e2e/successful-login-with-valid-passphrase.e2e.test.ts
Normal file
27
tests/e2e/successful-login-with-valid-passphrase.e2e.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Authentication', () => {
|
||||||
|
test('Successful Login with Valid Passphrase', async ({ page }) => {
|
||||||
|
// 1. Navigate to the application URL
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org');
|
||||||
|
|
||||||
|
// 2. On the login page, enter the valid AUTH_PASSPHRASE into the "Passphrase" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
|
||||||
|
// 3. Click the "Enter" button.
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - The user is redirected to a new session creation page (e.g., /session/<session-id>).
|
||||||
|
await expect(page).toHaveURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// - The "Harmonize Desires" heading is visible.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Harmonize Desires' })).toBeVisible();
|
||||||
|
|
||||||
|
// - The session creation form (Topic, Details, Number of Expected Responses, Start Session button) is displayed.
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Topic' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Details (Optional)' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('spinbutton', { name: 'Number of Expected Responses' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Start Session' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Authentication', () => {
|
||||||
|
test('Unsuccessful Login with Invalid Passphrase', async ({ page }) => {
|
||||||
|
// 1. Navigate to the application URL
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
|
||||||
|
// 2. On the login page, enter an invalid passphrase (e.g., "incorrect-passphrase") into the "Passphrase" textbox.
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('incorrect-passphrase');
|
||||||
|
|
||||||
|
// 3. Click the "Enter" button.
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - An error message indicating invalid credentials is displayed.
|
||||||
|
await expect(page.locator('.MuiAlert-standardError', { hasText: 'Invalid passphrase' })).toBeVisible();
|
||||||
|
|
||||||
|
// - The user remains on the login page.
|
||||||
|
await expect(page).toHaveURL(/.*\/login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
tests/e2e/verify-copy-link-button-functionality.e2e.test.ts
Normal file
25
tests/e2e/verify-copy-link-button-functionality.e2e.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Copy Link Feature', () => {
|
||||||
|
test('Verify Copy Link Button Functionality', async ({ page }) => {
|
||||||
|
// Ensure a session has been successfully created and the user is on the active session page.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Test Session for Copy Link');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Testing the copy link feature');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// 2. Click the "Copy Link" button.
|
||||||
|
await page.getByRole('button', { name: 'Copy Link' }).click();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - A success message (e.g., "Link copied to clipboard!") is displayed.
|
||||||
|
await expect(page.getByText('Link copied to clipboard!')).toBeVisible();
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
58
tests/e2e/verify-private-desires-are-not-shared.e2e.test.ts
Normal file
58
tests/e2e/verify-private-desires-are-not-shared.e2e.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Results Display Functionality', () => {
|
||||||
|
test.fixme('Verify Private Desires are Not Shared', async ({ page, browser }) => {
|
||||||
|
// 1. Follow steps for "4.2 Multi-User - All Participants Submit Desires", ensuring User 1 submits a "Afraid to Ask (Private)" item.
|
||||||
|
// Create a session with `Number of Expected Responses` set to 2.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Private Desires Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Verifying private desires are not shared between users.');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// Copy the session link.
|
||||||
|
await page.getByRole('button', { name: 'Copy Link' }).click();
|
||||||
|
await expect(page.getByText('Link copied to clipboard!')).toBeVisible();
|
||||||
|
const sessionLink = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
|
||||||
|
// User 1:
|
||||||
|
// a. Enter desires into "What You Want" and "Afraid to Ask (Private)".
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you want' }).fill('User1 Public Want');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter sensitive ideas privately' }).fill('User1 Secret Desire');
|
||||||
|
|
||||||
|
// b. Click "Submit Desires".
|
||||||
|
await page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
await expect(page.getByText('Waiting for 1 more responses...')).toBeVisible();
|
||||||
|
|
||||||
|
// User 2 (in a new browser context/page):
|
||||||
|
// a. Navigate to the copied session link.
|
||||||
|
const user2Context = await browser.newContext();
|
||||||
|
const user2Page = await user2Context.newPage();
|
||||||
|
await user2Page.goto(sessionLink);
|
||||||
|
|
||||||
|
// b. Enter desires into "What You Want".
|
||||||
|
// User 2 needs to log in first
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await user2Page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await user2Page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you want' }).fill('User2 Public Want');
|
||||||
|
|
||||||
|
// c. Click "Submit Desires".
|
||||||
|
await user2Page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(user2Page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - User 1's private desire is only visible to User 1 (if applicable, or not displayed at all on the results page).
|
||||||
|
await expect(page.getByText('User1 Secret Desire')).toBeVisible(); // User 1 can see their own private desire
|
||||||
|
await expect(user2Page.getByText('User1 Secret Desire')).not.toBeVisible(); // User 2 cannot see User 1's private desire
|
||||||
|
|
||||||
|
// - User 2's results page does not show User 1's private desire.
|
||||||
|
await expect(user2Page.getByText('User2 Public Want')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Results Display Functionality', () => {
|
||||||
|
test.fixme('Verify Results Display After All Submissions', async ({ page, browser }) => {
|
||||||
|
// 1. Follow steps for "4.2 Multi-User - All Participants Submit Desires" to reach the results page.
|
||||||
|
// Create a session with `Number of Expected Responses` set to 2.
|
||||||
|
await page.goto('https://unisono.aglink.duckdns.org/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
await page.getByRole('textbox', { name: 'Topic' }).fill('Results Display Test');
|
||||||
|
await page.getByRole('textbox', { name: 'Details (Optional)' }).fill('Verifying the results display after all submissions.');
|
||||||
|
await page.getByRole('spinbutton', { name: 'Number of Expected Responses' }).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Start Session' }).click();
|
||||||
|
await page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
// Copy the session link.
|
||||||
|
await page.getByRole('button', { name: 'Copy Link' }).click();
|
||||||
|
await expect(page.getByText('Link copied to clipboard!')).toBeVisible();
|
||||||
|
const sessionLink = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
|
||||||
|
// User 1:
|
||||||
|
// a. Enter desires into all categories.
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you want' }).fill('User1 Want A
|
||||||
|
User1 Want B');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter sensitive ideas privately' }).fill('User1 Secret Item');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you accept' }).fill('User1 Accept Item');
|
||||||
|
await page.getByRole('textbox', { name: 'Enter items you absolutely do not want' }).fill('User1 Unwanted Item');
|
||||||
|
|
||||||
|
// b. Click "Submit Desires".
|
||||||
|
await page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
await expect(page.getByText('Waiting for 1 more responses...')).toBeVisible();
|
||||||
|
|
||||||
|
// User 2 (in a new browser context/page):
|
||||||
|
// a. Navigate to the copied session link.
|
||||||
|
const user2Context = await browser.newContext();
|
||||||
|
const user2Page = await user2Context.newPage();
|
||||||
|
await user2Page.goto(sessionLink);
|
||||||
|
|
||||||
|
// b. Enter desires into all categories.
|
||||||
|
// User 2 needs to log in first
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Passphrase' }).fill('HonorableHumansPrivilegeIsToBeAllowedHere');
|
||||||
|
await user2Page.getByRole('button', { name: 'Enter' }).click();
|
||||||
|
await user2Page.waitForURL(/.*\/session\/.*/);
|
||||||
|
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you want' }).fill('User2 Want C
|
||||||
|
User2 Want D');
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter sensitive ideas privately' }).fill('User2 Secret Item');
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you accept' }).fill('User2 Accept Item');
|
||||||
|
await user2Page.getByRole('textbox', { name: 'Enter items you absolutely do not want' }).fill('User2 Unwanted Item');
|
||||||
|
|
||||||
|
// c. Click "Submit Desires".
|
||||||
|
await user2Page.getByRole('button', { name: 'Submit Desires' }).click();
|
||||||
|
await expect(user2Page.getByText('Your desires have been submitted.')).toBeVisible();
|
||||||
|
|
||||||
|
// Expected Results:
|
||||||
|
// - The "Results Display" page is visible.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Results' })).toBeVisible();
|
||||||
|
await expect(user2Page.getByRole('heading', { name: 'Results' })).toBeVisible();
|
||||||
|
|
||||||
|
// - All submitted desires (excluding private ones) are displayed in their respective categories (Want, Accept, Do Not Want).
|
||||||
|
await expect(page.getByText('User1 Want A')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Want B')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Want C')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Want D')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Accept Item')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Accept Item')).toBeVisible();
|
||||||
|
await expect(page.getByText('User1 Unwanted Item')).toBeVisible();
|
||||||
|
await expect(page.getByText('User2 Unwanted Item')).toBeVisible();
|
||||||
|
|
||||||
|
// - The count of participants who submitted desires matches the `Number of Expected Responses`.
|
||||||
|
// This would require more complex assertions based on the actual display of participant count.
|
||||||
|
|
||||||
|
// - The session topic and details are still visible.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Results Display Test' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Details: Verifying the results display after all submissions.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
177
tests/unisono-test-plan.md
Normal file
177
tests/unisono-test-plan.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Unisono Application - Comprehensive Test Plan
|
||||||
|
|
||||||
|
## Application Overview
|
||||||
|
|
||||||
|
The Unisono application facilitates harmonizing desires among participants through real-time collaboration. Users can create sessions, define a topic and optional details, specify the number of expected responses, and then share a link for others to join. The application uses a passphrase for authentication.
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### 1. Authentication
|
||||||
|
|
||||||
|
#### 1.1 Successful Login with Valid Passphrase
|
||||||
|
**Steps:**
|
||||||
|
1. Navigate to the application URL (e.g., `https://unisono.aglink.duckdns.org`).
|
||||||
|
2. On the login page, enter the valid `AUTH_PASSPHRASE` into the "Passphrase" textbox.
|
||||||
|
3. Click the "Enter" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- The user is redirected to a new session creation page (e.g., `/session/<session-id>`).
|
||||||
|
- The "Harmonize Desires" heading is visible.
|
||||||
|
- The session creation form (Topic, Details, Number of Expected Responses, Start Session button) is displayed.
|
||||||
|
|
||||||
|
#### 1.2 Unsuccessful Login with Invalid Passphrase
|
||||||
|
**Steps:**
|
||||||
|
1. Navigate to the application URL (e.g., `https://unisono.aglink.duckdns.org`).
|
||||||
|
2. On the login page, enter an invalid passphrase (e.g., "incorrect-passphrase") into the "Passphrase" textbox.
|
||||||
|
3. Click the "Enter" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- An error message indicating invalid credentials is displayed.
|
||||||
|
- The user remains on the login page.
|
||||||
|
|
||||||
|
### 2. Session Creation
|
||||||
|
|
||||||
|
#### 2.1 Create Session with Valid Data
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure the user is logged in and on the session creation page.
|
||||||
|
2. Enter a valid topic (e.g., "Project Alpha Planning") into the "Topic" textbox.
|
||||||
|
3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
4. Enter a valid number of participants (e.g., "3") into the "Number of Expected Responses" spinbutton.
|
||||||
|
5. Click the "Start Session" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- A new session is created.
|
||||||
|
- The user is redirected to the active session page.
|
||||||
|
- The session details (Topic, Details, Number of Expected Responses) are displayed correctly.
|
||||||
|
- A "Copy Link" button is visible.
|
||||||
|
|
||||||
|
#### 2.2 Attempt to Create Session with Missing Topic
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure the user is logged in and on the session creation page.
|
||||||
|
2. Leave the "Topic" textbox empty.
|
||||||
|
3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
4. Enter a valid number of participants (e.g., "3") into the "Number of Expected Responses" spinbutton.
|
||||||
|
5. Click the "Start Session" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- An error message indicating that the "Topic" field is required is displayed.
|
||||||
|
- The session is not created, and the user remains on the session creation page.
|
||||||
|
|
||||||
|
#### 2.3 Attempt to Create Session with Invalid Number of Participants (Less than 2)
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure the user is logged in and on the session creation page.
|
||||||
|
2. Enter a valid topic (e.g., "Project Alpha Planning") into the "Topic" textbox.
|
||||||
|
3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
4. Enter "1" into the "Number of Expected Responses" spinbutton.
|
||||||
|
5. Click the "Start Session" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- An error message indicating that the number of participants must be at least 2 is displayed.
|
||||||
|
- The session is not created, and the user remains on the session creation page.
|
||||||
|
|
||||||
|
#### 2.4 Attempt to Create Session with Invalid Number of Participants (More than 12)
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure the user is logged in and on the session creation page.
|
||||||
|
2. Enter a valid topic (e.g., "Project Alpha Planning") into the "Topic" textbox.
|
||||||
|
3. Enter optional details (e.g., "Discuss Q4 goals and allocate resources") into the "Details (Optional)" textbox.
|
||||||
|
4. Enter "13" into the "Number of Expected Responses" spinbutton.
|
||||||
|
5. Click the "Start Session" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- An error message indicating that the number of participants cannot exceed 12 is displayed.
|
||||||
|
- The session is not created, and the user remains on the session creation page.
|
||||||
|
|
||||||
|
### 3. Copy Link Feature
|
||||||
|
|
||||||
|
#### 3.1 Verify Copy Link Button Functionality
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure a session has been successfully created and the user is on the active session page.
|
||||||
|
2. Click the "Copy Link" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- A success message (e.g., "Link copied to clipboard!") is displayed.
|
||||||
|
- The session URL is copied to the clipboard.
|
||||||
|
|
||||||
|
### 4. Submit Desires Functionality
|
||||||
|
|
||||||
|
#### 4.1 Single User - Submit All Desire Categories
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure a session is created and the user is on the active session page.
|
||||||
|
2. Enter items into "What You Want" textbox (e.g., "Item A\nItem B").
|
||||||
|
3. Enter items into "Afraid to Ask (Private)" textbox (e.g., "Secret Item").
|
||||||
|
4. Enter items into "What You Accept" textbox (e.g., "Acceptable Item").
|
||||||
|
5. Enter items into "What You Do Not Want" textbox (e.g., "Unwanted Item").
|
||||||
|
6. Click the "Submit Desires" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- A success message indicating desires have been submitted is displayed.
|
||||||
|
- The input fields are cleared or disabled.
|
||||||
|
- The page transitions to a "Waiting for other participants" or similar state if `Expected Responses` > 1.
|
||||||
|
|
||||||
|
#### 4.2 Multi-User - All Participants Submit Desires
|
||||||
|
**Steps:**
|
||||||
|
1. Create a session with `Number of Expected Responses` set to 2.
|
||||||
|
2. Copy the session link.
|
||||||
|
3. **User 1:**
|
||||||
|
a. Enter desires into all categories.
|
||||||
|
b. Click "Submit Desires".
|
||||||
|
4. **User 2 (in a new browser context/page):**
|
||||||
|
a. Navigate to the copied session link.
|
||||||
|
b. Enter desires into all categories.
|
||||||
|
c. Click "Submit Desires".
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- After User 1 submits, their page shows a "Waiting for other participants" message.
|
||||||
|
- After User 2 submits, both User 1 and User 2's pages transition to the "Results Display" page.
|
||||||
|
- The "Results Display" page shows a summary of all submitted desires, categorized and potentially aggregated.
|
||||||
|
- Private desires are not visible to other users.
|
||||||
|
|
||||||
|
#### 4.3 Single User - Submit Only "What You Want"
|
||||||
|
**Steps:**
|
||||||
|
1. Ensure a session is created and the user is on the active session page.
|
||||||
|
2. Enter items into "What You Want" textbox (e.g., "Only Want This").
|
||||||
|
3. Leave other desire fields empty.
|
||||||
|
4. Click the "Submit Desires" button.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- Desires are submitted successfully.
|
||||||
|
- The page transitions to a "Waiting for other participants" or similar state.
|
||||||
|
|
||||||
|
#### 4.4 Multi-User - Different Desire Submissions
|
||||||
|
**Steps:**
|
||||||
|
1. Create a session with `Number of Expected Responses` set to 2.
|
||||||
|
2. Copy the session link.
|
||||||
|
3. **User 1:**
|
||||||
|
a. Enter items into "What You Want" (e.g., "User1 Want A").
|
||||||
|
b. Enter items into "What You Do Not Want" (e.g., "User1 Not Want B").
|
||||||
|
c. Click "Submit Desires".
|
||||||
|
4. **User 2 (in a new browser context/page):**
|
||||||
|
a. Navigate to the copied session link.
|
||||||
|
b. Enter items into "What You Want" (e.g., "User2 Want C").
|
||||||
|
c. Enter items into "What You Accept" (e.g., "User2 Accept D").
|
||||||
|
d. Click "Submit Desires".
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- Both users' pages transition to the "Results Display" page.
|
||||||
|
- The "Results Display" page accurately reflects the combined desires from both users, with correct categorization.
|
||||||
|
|
||||||
|
### 5. Results Display Functionality
|
||||||
|
|
||||||
|
#### 5.1 Verify Results Display After All Submissions
|
||||||
|
**Steps:**
|
||||||
|
1. Follow steps for "4.2 Multi-User - All Participants Submit Desires" to reach the results page.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- The "Results Display" page is visible.
|
||||||
|
- All submitted desires (excluding private ones) are displayed in their respective categories (Want, Accept, Do Not Want).
|
||||||
|
- The count of participants who submitted desires matches the `Number of Expected Responses`.
|
||||||
|
- The session topic and details are still visible.
|
||||||
|
|
||||||
|
#### 5.2 Verify Private Desires are Not Shared
|
||||||
|
**Steps:**
|
||||||
|
1. Follow steps for "4.2 Multi-User - All Participants Submit Desires", ensuring User 1 submits a "Afraid to Ask (Private)" item.
|
||||||
|
2. On User 2's results page, verify that User 1's private desire is *not* displayed.
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- User 1's private desire is only visible to User 1 (if applicable, or not displayed at all on the results page).
|
||||||
|
- User 2's results page does not show User 1's private desire.
|
||||||
Reference in New Issue
Block a user