1. Keep session alive with ping-pong. 2. Refreshed tests.

This commit is contained in:
AG
2025-10-16 10:48:11 +03:00
parent 6f64b1daca
commit 95684a34f7
27 changed files with 420 additions and 100 deletions

View File

@@ -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 multiplechoice questions render options as a Markdown table: - For multiplechoice 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 shortanswer 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 shortanswer 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:

View File

@@ -54,27 +54,60 @@ 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`
- **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 +115,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

View File

@@ -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")

View File

@@ -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
""" """

View File

@@ -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]"
} }
#============================================================================== #==============================================================================

41
backend/dist/index.js vendored
View File

@@ -12,14 +12,49 @@ 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,
});
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;

View File

@@ -13,24 +13,8 @@ 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 ws_1 = require("../ws"); // Import sessions, SessionState, broadcastToSession, and handleWebSocketMessage from ws/index.ts const ws_1 = require("../ws"); // Import sessions, SessionState, broadcastToSession, and handleWebSocketMessage from ws/index.ts
const router = express_1.default.Router(); const router = express_1.default.Router();
router.post('/sessions', (req, res) => {
const sessionId = (0, uuid_1.v4)();
ws_1.sessions.set(sessionId, {
state: ws_1.SessionState.SETUP,
topic: null,
description: null,
expectedResponses: 0,
submittedCount: 0,
responses: new Map(),
clients: new Map(),
finalResult: null,
});
console.log(`New session created: ${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 { userId, wants, accepts, afraidToAsk } = req.body;

View File

@@ -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"
} }

View File

@@ -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.

View File

@@ -92,6 +92,12 @@ const createWebSocketServer = (server) => {
} }
const sessionData = exports.sessions.get(sessionId); const sessionData = exports.sessions.get(sessionId);
console.log(`Client connecting to session: ${sessionId}`); console.log(`Client connecting to session: ${sessionId}`);
// Set up a ping interval to keep the connection alive
const pingInterval = setInterval(() => {
if (ws.readyState === ws_1.WebSocket.OPEN) {
ws.ping();
}
}, 30000); // Send ping every 30 seconds
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 { type, clientId, payload } = parsedMessage;
@@ -109,6 +115,7 @@ const createWebSocketServer = (server) => {
yield (0, exports.handleWebSocketMessage)(ws, sessionId, parsedMessage); 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()) { for (const [clientId, clientWs] of sessionData.clients.entries()) {
if (clientWs === ws) { if (clientWs === ws) {
@@ -154,6 +161,12 @@ const handleWebSocketMessage = (ws, sessionId, parsedMessage) => __awaiter(void
case 'REGISTER_CLIENT': case 'REGISTER_CLIENT':
console.log(`Client ${clientId} registered successfully for session ${sessionId}.`); console.log(`Client ${clientId} registered successfully for session ${sessionId}.`);
break; break;
case 'PING':
// Respond to client pings with a pong
if (ws.readyState === ws_1.WebSocket.OPEN) {
ws.pong();
}
break;
case 'SETUP_SESSION': case 'SETUP_SESSION':
if (sessionData.state === SessionState.SETUP) { if (sessionData.state === SessionState.SETUP) {
const { expectedResponses, topic, description } = payload; const { expectedResponses, topic, description } = payload;

View File

@@ -4,6 +4,21 @@ import { sessions, SessionState, broadcastToSession, handleWebSocketMessage } fr
const router = express.Router(); const router = express.Router();
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,
});
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 { userId, wants, accepts, afraidToAsk } = req.body;

View File

@@ -130,6 +130,13 @@ export const createWebSocketServer = (server: any) => {
console.log(`Client connecting to session: ${sessionId}`); console.log(`Client connecting to session: ${sessionId}`);
// 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 { type, clientId, payload } = parsedMessage;
@@ -151,6 +158,7 @@ export const createWebSocketServer = (server: any) => {
}); });
ws.on('close', () => { ws.on('close', () => {
clearInterval(pingInterval); // Clear the interval when the connection closes
let disconnectedClientId: string | null = null; let disconnectedClientId: string | null = null;
for (const [clientId, clientWs] of sessionData.clients.entries()) { for (const [clientId, clientWs] of sessionData.clients.entries()) {
if (clientWs === ws) { if (clientWs === ws) {
@@ -205,6 +213,13 @@ export const handleWebSocketMessage = async (ws: WebSocket, sessionId: string, p
console.log(`Client ${clientId} registered successfully for session ${sessionId}.`); console.log(`Client ${clientId} registered successfully for session ${sessionId}.`);
break; break;
case 'PING':
// Respond to client pings with a pong
if (ws.readyState === WebSocket.OPEN) {
ws.pong();
}
break;
case 'SETUP_SESSION': case 'SETUP_SESSION':
if (sessionData.state === SessionState.SETUP) { if (sessionData.state === SessionState.SETUP) {
const { expectedResponses, topic, description } = payload; const { expectedResponses, topic, description } = payload;

View File

@@ -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',

View File

@@ -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;

View File

@@ -0,0 +1,10 @@
{
"files": {
"main.js": "/static/js/main.d2d83152.js",
"index.html": "/index.html",
"main.d2d83152.js.map": "/static/js/main.d2d83152.js.map"
},
"entrypoints": [
"static/js/main.d2d83152.js"
]
}

View 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.d2d83152.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View 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
View 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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-is.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@@ -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",

View File

@@ -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();
}); });
}); });

View File

@@ -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`}
/> />

View File

@@ -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');
});
});
}); });

View File

@@ -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
@@ -33,6 +34,13 @@ class WebSocketService {
console.log('WebSocket connected'); console.log('WebSocket connected');
// Directly send registration message on open // Directly send registration message on open
this.sendMessage({ type: 'REGISTER_CLIENT' }); this.sendMessage({ type: 'REGISTER_CLIENT' });
// 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) {

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';