Compare commits
21 Commits
d7f5988a4f
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 57319e6712 | |||
| a553cc6130 | |||
| 5fc02f8d9b | |||
| ef0725be58 | |||
| b457e94ccb | |||
| 4abe8183d8 | |||
| bb650a3ed9 | |||
| 9df7278d55 | |||
| a8da8753f1 | |||
| 0ea3296885 | |||
| 39aea34026 | |||
| 1ff9a2cf4b | |||
| a520814771 | |||
| d072b71eec | |||
| 63c955c848 | |||
| a4739bddc1 | |||
| 57d5405c41 | |||
| e91886a1ce | |||
| 0c4934cf70 | |||
| d656b06113 | |||
| aa60098690 |
59
.claude/agents/playwright-test-generator.md
Normal file
59
.claude/agents/playwright-test-generator.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: playwright-test-generator
|
||||||
|
description: Use this agent when you need to create automated browser tests using Playwright. Examples: <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>
|
||||||
|
tools: Glob, Grep, Read, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||||
|
model: sonnet
|
||||||
|
color: blue
|
||||||
|
---
|
||||||
|
|
||||||
|
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>
|
||||||
45
.claude/agents/playwright-test-healer.md
Normal file
45
.claude/agents/playwright-test-healer.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: playwright-test-healer
|
||||||
|
description: Use this agent when you need to debug and fix failing Playwright tests. Examples: <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>
|
||||||
|
tools: Glob, Grep, Read, Write, Edit, MultiEdit, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||||
|
model: sonnet
|
||||||
|
color: red
|
||||||
|
---
|
||||||
|
|
||||||
|
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
|
||||||
93
.claude/agents/playwright-test-planner.md
Normal file
93
.claude/agents/playwright-test-planner.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: playwright-test-planner
|
||||||
|
description: Use this agent when you need to create comprehensive test plan for a web application or website. Examples: <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>
|
||||||
|
tools: Glob, Grep, Read, Write, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
|
||||||
|
model: sonnet
|
||||||
|
color: green
|
||||||
|
---
|
||||||
|
|
||||||
|
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.
|
||||||
63
.gitea/workflows/magnitude.yml
Normal file
63
.gitea/workflows/magnitude.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Gitea Actions workflow for running Magnitude tests
|
||||||
|
# Uses docker-compose.ci.yml for fully containerized testing
|
||||||
|
name: Magnitude Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, development]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, development]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create .env file for CI
|
||||||
|
run: |
|
||||||
|
cat > .env << EOF
|
||||||
|
SURREALDB_URL=ws://surrealdb:8000/rpc
|
||||||
|
SURREALDB_USER=root
|
||||||
|
SURREALDB_PASS=root
|
||||||
|
SURREALDB_NS=ponderants
|
||||||
|
SURREALDB_DB=main
|
||||||
|
SURREALDB_JWT_SECRET=${{ secrets.SURREALDB_JWT_SECRET }}
|
||||||
|
ATPROTO_CLIENT_ID=${{ secrets.ATPROTO_CLIENT_ID }}
|
||||||
|
ATPROTO_REDIRECT_URI=${{ secrets.ATPROTO_REDIRECT_URI }}
|
||||||
|
GOOGLE_GENERATIVE_AI_API_KEY=${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||||
|
DEEPGRAM_API_KEY=${{ secrets.DEEPGRAM_API_KEY }}
|
||||||
|
TEST_BLUESKY_HANDLE=${{ secrets.TEST_BLUESKY_HANDLE }}
|
||||||
|
TEST_BLUESKY_PASSWORD=${{ secrets.TEST_BLUESKY_PASSWORD }}
|
||||||
|
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Run tests with docker-compose
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.ci.yml --profile test up \
|
||||||
|
--abort-on-container-exit \
|
||||||
|
--exit-code-from magnitude
|
||||||
|
|
||||||
|
- name: Show logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "=== SurrealDB Logs ==="
|
||||||
|
docker compose -f docker-compose.ci.yml logs surrealdb
|
||||||
|
echo "=== Next.js Logs ==="
|
||||||
|
docker compose -f docker-compose.ci.yml logs nextjs
|
||||||
|
echo "=== Magnitude Logs ==="
|
||||||
|
docker compose -f docker-compose.ci.yml logs magnitude
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: magnitude-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f docker-compose.ci.yml down -v
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@@ -46,3 +47,6 @@ tests/playwright/.auth/
|
|||||||
|
|
||||||
# claude settings (keep .claude/CLAUDE.md but ignore user settings)
|
# claude settings (keep .claude/CLAUDE.md but ignore user settings)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# surrealdb data
|
||||||
|
surreal/data/
|
||||||
|
|||||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-test": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"playwright",
|
||||||
|
"run-test-mcp-server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
AGENTS.md
205
AGENTS.md
@@ -17,6 +17,211 @@ EOF
|
|||||||
|
|
||||||
These credentials should be used for all automated testing (Magnitude, Playwright) and manual testing when needed. Do not attempt to authenticate without using these credentials.
|
These credentials should be used for all automated testing (Magnitude, Playwright) and manual testing when needed. Do not attempt to authenticate without using these credentials.
|
||||||
|
|
||||||
|
**Database Setup**: The application uses SurrealDB running in Docker Compose for the app cache layer:
|
||||||
|
|
||||||
|
1. Start the database services:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. This starts two services:
|
||||||
|
- `surrealdb`: The main SurrealDB instance (port 8000)
|
||||||
|
- `surrealmcp`: SurrealMCP server for MCP access (port 8080)
|
||||||
|
|
||||||
|
3. Start the Next.js development server:
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. To stop the services:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Configuration:
|
||||||
|
- SurrealDB runs in-memory mode (data is not persisted between restarts)
|
||||||
|
- Namespace: `ponderants`
|
||||||
|
- Database: `main`
|
||||||
|
- Credentials: `root/root`
|
||||||
|
|
||||||
|
**Note**: Always start docker compose services before starting the Next.js dev server to ensure the database is available.
|
||||||
|
|
||||||
|
**Testing Workflow**: All new features must follow a rigorous testing process before being committed:
|
||||||
|
|
||||||
|
1. **Manual Testing with Playwright MCP**:
|
||||||
|
- Use Playwright MCP tools to manually test all functionality interactively
|
||||||
|
- Test both happy paths (expected user flows) and unhappy paths (errors, edge cases)
|
||||||
|
- Document each step you verify during manual testing - these become test cases
|
||||||
|
- If you encounter issues during manual testing (e.g., 404 errors, unexpected behavior), investigate and fix them before proceeding
|
||||||
|
- Use the following pattern:
|
||||||
|
```
|
||||||
|
1. Navigate to the feature
|
||||||
|
2. Perform user actions (clicks, typing, etc.)
|
||||||
|
3. Verify expected outcomes
|
||||||
|
4. Test error scenarios
|
||||||
|
5. Verify cleanup/state updates
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Write Comprehensive Magnitude Tests**:
|
||||||
|
- After manually verifying functionality with Playwright MCP, write extensive Magnitude tests covering ALL verified behaviors
|
||||||
|
- Each manual test step should have a corresponding Magnitude test assertion
|
||||||
|
- Test files are located in `tests/magnitude/` with `.mag.ts` extension
|
||||||
|
- Use the test credentials from .env (TEST_BLUESKY_HANDLE, TEST_BLUESKY_PASSWORD)
|
||||||
|
- Include both happy path and unhappy path test cases
|
||||||
|
- Example test structure:
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||||
|
|
||||||
|
test('Feature description', async (agent) => {
|
||||||
|
await agent.act('Navigate to /page');
|
||||||
|
await agent.act('Perform user action');
|
||||||
|
await agent.check('Verify expected outcome');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reusable Playwright Scaffolding**:
|
||||||
|
- Abstract common patterns (auth, navigation, etc.) into helper files in `tests/playwright/helpers/`
|
||||||
|
- These helpers should be usable both during manual Playwright MCP testing AND by Magnitude tests
|
||||||
|
- Examples: `tests/playwright/helpers/chat.ts`, `tests/playwright/helpers/galaxy.ts`, `tests/playwright/helpers/node.ts`
|
||||||
|
- For auth setup, use Playwright's global setup pattern (see https://playwright.dev/docs/test-global-setup-teardown)
|
||||||
|
- Current auth setup: `tests/playwright/auth.setup.ts`
|
||||||
|
|
||||||
|
4. **Generating Playwright Code**:
|
||||||
|
- Use https://playwright.dev/docs/test-agents to generate Playwright test code when helpful
|
||||||
|
- This tool can convert natural language test descriptions into Playwright code
|
||||||
|
|
||||||
|
5. **Test Execution**:
|
||||||
|
- Run Magnitude tests: `pnpm test` or `npx magnitude`
|
||||||
|
- Ensure ALL tests pass before committing
|
||||||
|
- If tests fail, fix the implementation or update the tests to match the correct behavior
|
||||||
|
|
||||||
|
6. **Pre-Commit Checklist**:
|
||||||
|
- ✅ All manual testing with Playwright MCP completed and verified
|
||||||
|
- ✅ All Magnitude tests written and cover all verified functionality
|
||||||
|
- ✅ Database verified for expected state after operations (e.g., deletions actually removed records)
|
||||||
|
- ✅ Run magnitude tests for current feature FIRST: `pnpm test tests/magnitude/your-feature.mag.ts`
|
||||||
|
- ✅ Verify current feature tests pass
|
||||||
|
- ✅ Run ALL magnitude tests: `pnpm test`
|
||||||
|
- ✅ Verify ENTIRE test suite passes
|
||||||
|
- ✅ No console errors or warnings in production code paths
|
||||||
|
- **CRITICAL**: Do NOT commit until ALL tests pass - feature tests AND full test suite
|
||||||
|
- Only commit after ALL checklist items are complete
|
||||||
|
|
||||||
|
7. **Documentation**:
|
||||||
|
- Document test coverage in `tests/README.md`
|
||||||
|
- Add comments to complex test scenarios explaining the business logic being tested
|
||||||
|
|
||||||
|
**Testing Resources**:
|
||||||
|
- Playwright Global Setup/Teardown: https://playwright.dev/docs/test-global-setup-teardown
|
||||||
|
- Playwright Test Agents: https://playwright.dev/docs/test-agents
|
||||||
|
- Playwright Docker: https://playwright.dev/docs/docker
|
||||||
|
- Magnitude.run Documentation: https://magnitude.run/docs
|
||||||
|
- Project Test README: `tests/README.md`
|
||||||
|
|
||||||
|
**Playwright Docker Setup**:
|
||||||
|
|
||||||
|
Playwright is integrated into docker-compose for consistent testing environments:
|
||||||
|
|
||||||
|
1. **Run Playwright tests with docker-compose**:
|
||||||
|
```bash
|
||||||
|
# Start database services
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Start Next.js dev server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Run Playwright tests in Docker (in another terminal)
|
||||||
|
docker compose run --rm playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Alternative: Use the 'test' profile**:
|
||||||
|
```bash
|
||||||
|
# Start all services including Playwright
|
||||||
|
docker compose --profile test up
|
||||||
|
|
||||||
|
# Or run tests one-off without keeping services up
|
||||||
|
docker compose --profile test run --rm playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Benefits**:
|
||||||
|
- Non-root user execution (pwuser) for security
|
||||||
|
- Consistent browser versions across environments
|
||||||
|
- Integrated with existing docker-compose setup
|
||||||
|
- Uses host networking to access dev server on localhost:3000
|
||||||
|
- Node modules volume prevents permission issues
|
||||||
|
|
||||||
|
4. **Configuration**:
|
||||||
|
- Environment variables loaded from .env file
|
||||||
|
- Uses `network_mode: host` to access dev server
|
||||||
|
- Runs with `profiles: [test]` to keep it optional
|
||||||
|
|
||||||
|
**CI/CD with Gitea Actions**:
|
||||||
|
|
||||||
|
Magnitude tests run automatically on every push and pull request using a fully containerized setup:
|
||||||
|
|
||||||
|
1. **Configuration**: `.gitea/workflows/magnitude.yml`
|
||||||
|
|
||||||
|
2. **Workflow steps** (simplified to just 2 steps!):
|
||||||
|
- Create `.env` file with secrets
|
||||||
|
- Run `docker compose -f docker-compose.ci.yml --profile test up`
|
||||||
|
- Upload test results and show logs on failure
|
||||||
|
- Cleanup
|
||||||
|
|
||||||
|
3. **Required Secrets** (configure in Gitea repository settings):
|
||||||
|
- `ANTHROPIC_API_KEY` - For Magnitude AI vision testing
|
||||||
|
- `TEST_BLUESKY_HANDLE` - Test account handle
|
||||||
|
- `TEST_BLUESKY_PASSWORD` - Test account password
|
||||||
|
- `ATPROTO_CLIENT_ID`, `ATPROTO_REDIRECT_URI`
|
||||||
|
- `GOOGLE_API_KEY`, `DEEPGRAM_API_KEY`
|
||||||
|
- `SURREAL_JWT_SECRET`
|
||||||
|
|
||||||
|
4. **CI-specific docker-compose**: `docker-compose.ci.yml`
|
||||||
|
- Fully containerized (SurrealDB + Next.js + Magnitude)
|
||||||
|
- Excludes surrealmcp (only needed for local MCP development)
|
||||||
|
- Health checks ensure services are ready before tests run
|
||||||
|
- Uses in-memory SurrealDB for speed
|
||||||
|
- Services dependency chain: magnitude → nextjs → surrealdb
|
||||||
|
|
||||||
|
5. **Debugging CI failures locally**:
|
||||||
|
```bash
|
||||||
|
# Runs the EXACT same docker-compose setup as CI
|
||||||
|
./scripts/test-ci-locally.sh
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
docker compose -f docker-compose.ci.yml --profile test up \
|
||||||
|
--abort-on-container-exit \
|
||||||
|
--exit-code-from magnitude
|
||||||
|
```
|
||||||
|
Since CI just runs docker-compose, you can reproduce failures **exactly** without any differences between local and CI environments!
|
||||||
|
|
||||||
|
6. **Test results**: Available as workflow artifacts for 30 days
|
||||||
|
|
||||||
|
7. **Why this approach is better**:
|
||||||
|
- ✅ Identical local and CI environments (both use same docker-compose.ci.yml)
|
||||||
|
- ✅ Fast debugging (no push-test-fail cycles)
|
||||||
|
- ✅ Self-contained (all dependencies in containers)
|
||||||
|
- ✅ Simple (just 2 steps in CI workflow)
|
||||||
|
- ✅ Reproducible (docker-compose ensures consistency)
|
||||||
|
|
||||||
|
**Testing Framework Separation**:
|
||||||
|
|
||||||
|
- **Playwright**: Used for manual testing with Playwright MCP and global auth setup
|
||||||
|
- Location: `tests/playwright/`
|
||||||
|
- Helpers: `tests/playwright/helpers.ts`
|
||||||
|
- Auth setup: `tests/playwright/auth.setup.ts`
|
||||||
|
- Run: `npx playwright test`
|
||||||
|
|
||||||
|
- **Magnitude**: Used for automated end-to-end testing in development and CI/CD
|
||||||
|
- Location: `tests/magnitude/`
|
||||||
|
- Helpers: `tests/magnitude/helpers.ts`
|
||||||
|
- Configuration: `magnitude.config.ts` (uses Claude Sonnet 4.5)
|
||||||
|
- Run: `npx magnitude` or `pnpm test`
|
||||||
|
|
||||||
|
Both frameworks are independent and can be used separately or together depending on the testing need.
|
||||||
|
|
||||||
You are an expert-level, full-stack AI coding agent. Your task is to implement
|
You are an expert-level, full-stack AI coding agent. Your task is to implement
|
||||||
the "Ponderants" application. Product Vision: Ponderants is an AI-powered
|
the "Ponderants" application. Product Vision: Ponderants is an AI-powered
|
||||||
thought partner that interviews a user to capture, structure, and visualize
|
thought partner that interviews a user to capture, structure, and visualize
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import { verifySurrealJwt } from '@/lib/auth/jwt';
|
|||||||
/**
|
/**
|
||||||
* POST /api/calculate-graph
|
* POST /api/calculate-graph
|
||||||
*
|
*
|
||||||
* Calculates 3D coordinates for all nodes using UMAP dimensionality reduction.
|
* Calculates 3D coordinates for ALL nodes using UMAP dimensionality reduction.
|
||||||
* This route:
|
* This route:
|
||||||
* 1. Fetches all nodes with embeddings but no 3D coordinates
|
* 1. Fetches ALL nodes with embeddings (including those with existing coords)
|
||||||
* 2. Runs UMAP to reduce embeddings from 768-D to 3-D
|
* 2. Runs UMAP to reduce embeddings from 3072-D to 3-D
|
||||||
* 3. Updates each node with its calculated 3D coordinates
|
* 3. Updates ALL nodes with their recalculated 3D coordinates
|
||||||
|
*
|
||||||
|
* Note: UMAP is a manifold learning algorithm that needs to see ALL data points
|
||||||
|
* together to create a consistent embedding space. We can't incrementally add
|
||||||
|
* new nodes - we must recalculate the entire graph each time. This means the
|
||||||
|
* galaxy "reorganizes" when you add nodes, which is correct behavior.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -32,18 +37,18 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const db = await connectToDB();
|
const db = await connectToDB();
|
||||||
|
|
||||||
// 1. Fetch all nodes that have an embedding but no coords_3d (filtered by user_did)
|
// 1. Fetch ALL nodes that have an embedding (filtered by user_did)
|
||||||
// This query is idempotent - it's safe to run multiple times
|
// We recalculate ALL nodes together because UMAP is a manifold learning
|
||||||
const query = `SELECT id, embedding FROM node WHERE user_did = $userDid AND embedding != NONE AND coords_3d = NONE`;
|
// algorithm that needs to see the full dataset to create consistent coordinates.
|
||||||
|
const query = `SELECT id, embedding FROM node WHERE user_did = $userDid AND embedding != NONE`;
|
||||||
const results = await db.query<[Array<{ id: string; embedding: number[] }>]>(query, { userDid });
|
const results = await db.query<[Array<{ id: string; embedding: number[] }>]>(query, { userDid });
|
||||||
|
|
||||||
const nodes = results[0] || [];
|
const nodes = results[0] || [];
|
||||||
|
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
// All nodes already have coordinates - nothing to do (idempotency)
|
console.log('[Calculate Graph] No nodes with embeddings found');
|
||||||
console.log('[Calculate Graph] All nodes already have coordinates');
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'All nodes already have coordinates', nodes_mapped: 0 },
|
{ message: 'No nodes with embeddings found. Create nodes with content.' },
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,12 +62,12 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Calculate Graph] Processing ${nodes.length} nodes for UMAP projection`);
|
console.log(`[Calculate Graph] Recalculating coordinates for ${nodes.length} nodes`);
|
||||||
|
|
||||||
// 2. Prepare data for UMAP
|
// 2. Prepare data for UMAP
|
||||||
const embeddings = nodes.map((n) => n.embedding);
|
const embeddings = nodes.map((n) => n.embedding);
|
||||||
|
|
||||||
// 3. Run UMAP to reduce 768-D (or 1536-D) to 3-D
|
// 3. Run UMAP to reduce 3072-D embeddings to 3-D coordinates
|
||||||
const umap = new UMAP({
|
const umap = new UMAP({
|
||||||
nComponents: 3,
|
nComponents: 3,
|
||||||
nNeighbors: Math.min(15, nodes.length - 1), // nNeighbors must be < sample size
|
nNeighbors: Math.min(15, nodes.length - 1), // nNeighbors must be < sample size
|
||||||
@@ -74,7 +79,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const coords_3d_array = await umap.fitAsync(embeddings);
|
const coords_3d_array = await umap.fitAsync(embeddings);
|
||||||
console.log('[Calculate Graph] ✓ UMAP projection complete');
|
console.log('[Calculate Graph] ✓ UMAP projection complete');
|
||||||
|
|
||||||
// 4. Update nodes in SurrealDB with their new 3D coords
|
// 4. Update ALL nodes in SurrealDB with their recalculated 3D coords
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
const node = nodes[i];
|
const node = nodes[i];
|
||||||
const coords = coords_3d_array[i];
|
const coords = coords_3d_array[i];
|
||||||
@@ -84,11 +89,11 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Calculate Graph] ✓ Updated ${nodes.length} nodes with 3D coordinates`);
|
console.log(`[Calculate Graph] ✓ Recalculated and updated ${nodes.length} nodes with 3D coordinates`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
nodes_mapped: nodes.length,
|
nodes_recalculated: nodes.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Calculate Graph] Error:', error);
|
console.error('[Calculate Graph] Error:', error);
|
||||||
|
|||||||
@@ -21,42 +21,71 @@ interface LinkData {
|
|||||||
* GET /api/galaxy
|
* GET /api/galaxy
|
||||||
*
|
*
|
||||||
* Fetches nodes with 3D coordinates and their links for visualization.
|
* Fetches nodes with 3D coordinates and their links for visualization.
|
||||||
* Automatically triggers graph calculation if needed.
|
* Supports public viewing via ?user={did} parameter.
|
||||||
|
* If no user parameter is provided and user is authenticated, shows their own galaxy.
|
||||||
|
* If no user parameter and not authenticated, returns empty state with guidance.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const targetUserDid = searchParams.get('user');
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||||
|
|
||||||
if (!surrealJwt) {
|
// Determine which user's galaxy to show
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
let userDid: string;
|
||||||
}
|
let isOwnGalaxy = false;
|
||||||
|
|
||||||
// Verify JWT to get user's DID
|
if (targetUserDid) {
|
||||||
const userSession = verifySurrealJwt(surrealJwt);
|
// Viewing someone else's public galaxy
|
||||||
if (!userSession) {
|
userDid = targetUserDid;
|
||||||
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
console.log(`[Galaxy API] Fetching public galaxy for user: ${userDid}`);
|
||||||
|
} else if (surrealJwt) {
|
||||||
|
// Viewing own galaxy (authenticated)
|
||||||
|
const userSession = verifySurrealJwt(surrealJwt);
|
||||||
|
if (!userSession) {
|
||||||
|
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
userDid = userSession.did;
|
||||||
|
isOwnGalaxy = true;
|
||||||
|
console.log(`[Galaxy API] Fetching own galaxy for user: ${userDid}`);
|
||||||
|
} else {
|
||||||
|
// No target user and not authenticated - return empty galaxy with message
|
||||||
|
console.log('[Galaxy API] No user specified and not authenticated');
|
||||||
|
return NextResponse.json({
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
message: 'Log in to view your galaxy, or visit a public galaxy via ?user={did}',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { did: userDid } = userSession;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await connectToDB();
|
const db = await connectToDB();
|
||||||
|
|
||||||
// Fetch nodes that have 3D coordinates
|
// Fetch nodes that have 3D coordinates
|
||||||
|
// When viewing own galaxy, show all nodes (including private)
|
||||||
|
// When viewing public galaxy, only show nodes where is_public = true
|
||||||
const nodesQuery = `
|
const nodesQuery = `
|
||||||
SELECT id, title, body, user_did, atp_uri, coords_3d
|
SELECT id, title, body, user_did, atp_uri, coords_3d
|
||||||
FROM node
|
FROM node
|
||||||
WHERE user_did = $userDid AND coords_3d != NONE
|
WHERE user_did = $userDid
|
||||||
|
AND coords_3d != NONE
|
||||||
|
${isOwnGalaxy ? '' : 'AND is_public = true'}
|
||||||
`;
|
`;
|
||||||
const nodeResults = await db.query<[NodeData[]]>(nodesQuery, { userDid });
|
const nodeResults = await db.query<[NodeData[]]>(nodesQuery, { userDid });
|
||||||
const nodes = nodeResults[0] || [];
|
const nodes = nodeResults[0] || [];
|
||||||
|
|
||||||
// Fetch links between nodes
|
// Fetch links between visible nodes
|
||||||
|
// Extract node IDs for filtering links
|
||||||
|
const nodeIds = nodes.map((n) => n.id);
|
||||||
|
|
||||||
|
// Only fetch links where both endpoints are in our visible nodes
|
||||||
const linksQuery = `
|
const linksQuery = `
|
||||||
SELECT in, out
|
SELECT in, out
|
||||||
FROM links_to
|
FROM links_to
|
||||||
|
WHERE in IN $nodeIds AND out IN $nodeIds
|
||||||
`;
|
`;
|
||||||
const linkResults = await db.query<[LinkData[]]>(linksQuery);
|
const linkResults = await db.query<[LinkData[]]>(linksQuery, { nodeIds });
|
||||||
const links = linkResults[0] || [];
|
const links = linkResults[0] || [];
|
||||||
|
|
||||||
// Note: Coordinate calculation is now triggered automatically when nodes are created
|
// Note: Coordinate calculation is now triggered automatically when nodes are created
|
||||||
|
|||||||
140
app/api/nodes/[id]/route.ts
Normal file
140
app/api/nodes/[id]/route.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { Agent } from '@atproto/api';
|
||||||
|
import { connectToDB } from '@/lib/db';
|
||||||
|
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
||||||
|
import { getOAuthClient } from '@/lib/auth/oauth-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/nodes/[id]
|
||||||
|
*
|
||||||
|
* Deletes a node from both ATproto (source of truth) and SurrealDB (cache).
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Verify user authentication and ownership
|
||||||
|
* 2. Fetch node from SurrealDB to get atp_uri
|
||||||
|
* 3. Delete post(s) from ATproto/Bluesky
|
||||||
|
* 4. Delete node from SurrealDB cache
|
||||||
|
*
|
||||||
|
* Note: ATproto is the source of truth. If ATproto deletion fails, we don't delete from cache.
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||||
|
|
||||||
|
console.log('[DELETE /api/nodes/[id]] Auth check:', {
|
||||||
|
hasSurrealJwt: !!surrealJwt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!surrealJwt) {
|
||||||
|
console.error('[DELETE /api/nodes/[id]] Missing auth cookie');
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the JWT and extract user info
|
||||||
|
const userSession = verifySurrealJwt(surrealJwt);
|
||||||
|
if (!userSession) {
|
||||||
|
console.error('[DELETE /api/nodes/[id]] Invalid JWT');
|
||||||
|
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { did: userDid } = userSession;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
console.log('[DELETE /api/nodes/[id]] Deleting node:', { nodeId: id, userDid });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch the node from SurrealDB to verify ownership and get atp_uri
|
||||||
|
const db = await connectToDB();
|
||||||
|
|
||||||
|
// Parse the ID to extract table and record ID parts
|
||||||
|
// Format: "node:e9e38d09-c0f4-4834-a6ba-c92dfa4c0910" or "node:⟨e9e38d09-c0f4-4834-a6ba-c92dfa4c0910⟩"
|
||||||
|
const cleanId = id.replace(/^node:/, '').replace(/[⟨⟩]/g, '');
|
||||||
|
|
||||||
|
const nodeResult = await db.query<[Array<{ id: string; user_did: string; atp_uri: string }>]>(
|
||||||
|
'SELECT id, user_did, atp_uri FROM node WHERE id = type::thing($table, $recordId)',
|
||||||
|
{ table: 'node', recordId: cleanId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = nodeResult[0]?.[0];
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
console.error('[DELETE /api/nodes/[id]] Node not found:', id);
|
||||||
|
return NextResponse.json({ error: 'Node not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify ownership
|
||||||
|
if (node.user_did !== userDid) {
|
||||||
|
console.error('[DELETE /api/nodes/[id]] Unauthorized: user does not own node');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You do not have permission to delete this node' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete from ATproto (source of truth)
|
||||||
|
try {
|
||||||
|
const client = await getOAuthClient();
|
||||||
|
console.log('[DELETE /api/nodes/[id]] Got OAuth client, restoring session for DID:', userDid);
|
||||||
|
|
||||||
|
const session = await client.restore(userDid);
|
||||||
|
const agent = new Agent(session);
|
||||||
|
|
||||||
|
console.log('[DELETE /api/nodes/[id]] Successfully restored OAuth session');
|
||||||
|
|
||||||
|
// Parse the atp_uri to get repo and rkey
|
||||||
|
// Format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
|
||||||
|
const atUriMatch = node.atp_uri.match(/at:\/\/([^/]+)\/([^/]+)\/(.+)/);
|
||||||
|
if (!atUriMatch) {
|
||||||
|
throw new Error(`Invalid atp_uri format: ${node.atp_uri}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, repo, collection, rkey] = atUriMatch;
|
||||||
|
|
||||||
|
console.log('[DELETE /api/nodes/[id]] Deleting ATproto record:', {
|
||||||
|
repo,
|
||||||
|
collection,
|
||||||
|
rkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the post from ATproto
|
||||||
|
await agent.api.com.atproto.repo.deleteRecord({
|
||||||
|
repo,
|
||||||
|
collection,
|
||||||
|
rkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[DELETE /api/nodes/[id]] ✓ Deleted post from ATproto');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DELETE /api/nodes/[id]] ATproto deletion error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete post from Bluesky. Node not deleted.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Delete from SurrealDB cache (only after successful ATproto deletion)
|
||||||
|
try {
|
||||||
|
// Use type::thing() to properly construct the RecordId for deletion
|
||||||
|
await db.query('DELETE FROM type::thing($table, $recordId)', {
|
||||||
|
table: 'node',
|
||||||
|
recordId: cleanId,
|
||||||
|
});
|
||||||
|
console.log('[DELETE /api/nodes/[id]] ✓ Deleted node from SurrealDB cache');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[DELETE /api/nodes/[id]] ⚠ SurrealDB cache deletion failed (non-critical):', error);
|
||||||
|
// This is non-critical since ATproto (source of truth) was successfully deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, nodeId: id });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DELETE /api/nodes/[id]] Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete node' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/api/nodes/debug/route.ts
Normal file
66
app/api/nodes/debug/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { connectToDB } from '@/lib/db';
|
||||||
|
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/nodes/debug
|
||||||
|
*
|
||||||
|
* Debug endpoint to list all nodes for the current user in SurrealDB.
|
||||||
|
* Only available in development mode.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
// Only allow in development
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
return NextResponse.json({ error: 'Not available in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||||
|
|
||||||
|
console.log('[DEBUG /api/nodes/debug] Auth check:', {
|
||||||
|
hasSurrealJwt: !!surrealJwt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!surrealJwt) {
|
||||||
|
console.error('[DEBUG /api/nodes/debug] Missing auth cookie');
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the JWT and extract user info
|
||||||
|
const userSession = verifySurrealJwt(surrealJwt);
|
||||||
|
if (!userSession) {
|
||||||
|
console.error('[DEBUG /api/nodes/debug] Invalid JWT');
|
||||||
|
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { did: userDid } = userSession;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await connectToDB();
|
||||||
|
|
||||||
|
// Fetch ALL nodes for this user (no filters)
|
||||||
|
const nodesResult = await db.query<
|
||||||
|
[Array<{ id: string; title: string; body: string; user_did: string; atp_uri: string }>]
|
||||||
|
>('SELECT id, title, body, user_did, atp_uri FROM node WHERE user_did = $userDid', {
|
||||||
|
userDid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes = nodesResult[0] || [];
|
||||||
|
|
||||||
|
console.log('[DEBUG /api/nodes/debug] Found nodes:', {
|
||||||
|
count: nodes.length,
|
||||||
|
userDid,
|
||||||
|
nodeIds: nodes.map((n) => n.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
nodes,
|
||||||
|
userDid,
|
||||||
|
count: nodes.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DEBUG /api/nodes/debug] Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch nodes' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -257,14 +257,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Handle linking
|
// Handle linking
|
||||||
if (links && links.length > 0) {
|
if (links && links.length > 0) {
|
||||||
// Find the corresponding cache nodes for the AT-URIs
|
// Links array contains node IDs (e.g., "node:xxxxx") from the UI
|
||||||
|
// Verify they belong to this user before creating relations
|
||||||
const targetNodesResult = await db.query<[Array<{ id: string }>]>(
|
const targetNodesResult = await db.query<[Array<{ id: string }>]>(
|
||||||
'SELECT id FROM node WHERE user_did = $did AND atp_uri IN $links',
|
'SELECT id FROM node WHERE user_did = $did AND id IN $links',
|
||||||
{ did: userDid, links: links }
|
{ did: userDid, links: links }
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetNodes = targetNodesResult[0] || [];
|
const targetNodes = targetNodesResult[0] || [];
|
||||||
|
|
||||||
|
console.log(`[POST /api/nodes] Creating ${targetNodes.length} link relations`);
|
||||||
|
|
||||||
// Create graph relations
|
// Create graph relations
|
||||||
for (const targetNode of targetNodes) {
|
for (const targetNode of targetNodes) {
|
||||||
await db.query('RELATE $from->links_to->$to', {
|
await db.query('RELATE $from->links_to->$to', {
|
||||||
|
|||||||
58
components/DeleteNodeModal.tsx
Normal file
58
components/DeleteNodeModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Modal, Stack, Text, Group, Button } from '@mantine/core';
|
||||||
|
import { IconTrash } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
interface DeleteNodeModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
nodeTitle: string | null;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteNodeModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
nodeTitle,
|
||||||
|
isDeleting,
|
||||||
|
}: DeleteNodeModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Delete Node"
|
||||||
|
centered
|
||||||
|
zIndex={1001}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete "{nodeTitle}"? This will:
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs" ml="md">
|
||||||
|
<Text size="sm">• Remove the post from Bluesky</Text>
|
||||||
|
<Text size="sm">• Delete the node from your galaxy</Text>
|
||||||
|
<Text size="sm" fw={600} c="red">This action cannot be undone.</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={isDeleting}
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
>
|
||||||
|
Delete Permanently
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import { IconMessageCircle, IconEdit, IconChartBubbleFilled } from '@tabler/icon
|
|||||||
import { useSelector } from '@xstate/react';
|
import { useSelector } from '@xstate/react';
|
||||||
import { useAppMachine } from '@/hooks/useAppMachine';
|
import { useAppMachine } from '@/hooks/useAppMachine';
|
||||||
import { UserMenu } from '@/components/UserMenu';
|
import { UserMenu } from '@/components/UserMenu';
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
||||||
import styles from './DesktopSidebar.module.css';
|
import styles from './DesktopSidebar.module.css';
|
||||||
|
|
||||||
export function DesktopSidebar() {
|
export function DesktopSidebar() {
|
||||||
@@ -106,10 +105,7 @@ export function DesktopSidebar() {
|
|||||||
|
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* User Menu - styled like other nav items, now includes theme toggle */}
|
||||||
<ThemeToggle />
|
|
||||||
|
|
||||||
{/* User Menu - styled like other nav items */}
|
|
||||||
<UserMenu showLabel={true} />
|
<UserMenu showLabel={true} />
|
||||||
|
|
||||||
{/* Development state panel */}
|
{/* Development state panel */}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@react-three/drei';
|
} from '@react-three/drei';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme } from '@mantine/core';
|
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button } from '@mantine/core';
|
||||||
|
import { IconTrash } from '@tabler/icons-react';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { DeleteNodeModal } from './DeleteNodeModal';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
@@ -95,18 +98,50 @@ export function ThoughtGalaxy() {
|
|||||||
const [nodes, setNodes] = useState<NodeData[]>([]);
|
const [nodes, setNodes] = useState<NodeData[]>([]);
|
||||||
const [links, setLinks] = useState<LinkData[]>([]);
|
const [links, setLinks] = useState<LinkData[]>([]);
|
||||||
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||||
|
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
|
||||||
|
const [currentUserDid, setCurrentUserDid] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const cameraControlsRef = useRef<CameraControls>(null);
|
const cameraControlsRef = useRef<CameraControls>(null);
|
||||||
const hasFitCamera = useRef(false);
|
const hasFitCamera = useRef(false);
|
||||||
const hasFocusedNode = useRef<string | null>(null);
|
const hasFocusedNode = useRef<string | null>(null);
|
||||||
|
|
||||||
// Get selectedNodeId from URL query params
|
// Get query params
|
||||||
const selectedNodeId = searchParams.get('node');
|
const selectedNodeId = searchParams.get('node');
|
||||||
|
const targetUserDid = searchParams.get('user'); // For viewing someone else's galaxy
|
||||||
|
|
||||||
|
// Fetch current user's profile to get their DID
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCurrentUser() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/profile', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentUserDid(data.did);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThoughtGalaxy] Error fetching current user:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fetch current user if we're viewing our own galaxy
|
||||||
|
if (!targetUserDid) {
|
||||||
|
fetchCurrentUser();
|
||||||
|
}
|
||||||
|
}, [targetUserDid]);
|
||||||
|
|
||||||
// Fetch data from API on mount and poll for updates
|
// Fetch data from API on mount and poll for updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/galaxy', {
|
// Build URL with optional user parameter
|
||||||
|
const url = targetUserDid
|
||||||
|
? `/api/galaxy?user=${encodeURIComponent(targetUserDid)}`
|
||||||
|
: '/api/galaxy';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
credentials: 'include', // Include cookies for authentication
|
credentials: 'include', // Include cookies for authentication
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,13 +154,17 @@ export function ThoughtGalaxy() {
|
|||||||
|
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
console.log('[ThoughtGalaxy]', data.message);
|
console.log('[ThoughtGalaxy]', data.message);
|
||||||
|
setEmptyMessage(data.message);
|
||||||
// If calculating, poll again in 2 seconds
|
// If calculating, poll again in 2 seconds
|
||||||
setTimeout(fetchData, 2000);
|
if (data.message.includes('calculating')) {
|
||||||
|
setTimeout(fetchData, 2000);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNodes(data.nodes || []);
|
setNodes(data.nodes || []);
|
||||||
setLinks(data.links || []);
|
setLinks(data.links || []);
|
||||||
|
setEmptyMessage(null);
|
||||||
|
|
||||||
console.log(`[ThoughtGalaxy] Loaded ${data.nodes?.length || 0} nodes and ${data.links?.length || 0} links`);
|
console.log(`[ThoughtGalaxy] Loaded ${data.nodes?.length || 0} nodes and ${data.links?.length || 0} links`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -134,7 +173,7 @@ export function ThoughtGalaxy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, [targetUserDid]);
|
||||||
|
|
||||||
// Function to fit camera to all nodes
|
// Function to fit camera to all nodes
|
||||||
const fitCameraToNodes = () => {
|
const fitCameraToNodes = () => {
|
||||||
@@ -279,6 +318,51 @@ export function ThoughtGalaxy() {
|
|||||||
router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false });
|
router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle deleting a node
|
||||||
|
const handleDeleteNode = async () => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
|
||||||
|
const cleanId = String(selectedNode.id).replace(/[⟨⟩]/g, '');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/nodes/${cleanId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to delete node');
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Node deleted',
|
||||||
|
message: 'Node has been deleted from Bluesky and your galaxy',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the node from local state
|
||||||
|
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== selectedNode.id));
|
||||||
|
setLinks((prevLinks) => prevLinks.filter((l) => l.in !== selectedNode.id && l.out !== selectedNode.id));
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThoughtGalaxy] Delete error:', error);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Delete failed',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to delete node',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines');
|
console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines');
|
||||||
|
|
||||||
// Show message if no nodes are ready yet
|
// Show message if no nodes are ready yet
|
||||||
@@ -286,17 +370,49 @@ export function ThoughtGalaxy() {
|
|||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
|
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
|
||||||
<MantineText size="lg" c="dimmed">
|
<MantineText size="lg" c="dimmed">
|
||||||
Create at least 3 nodes to visualize your thought galaxy
|
{emptyMessage || 'Create at least 3 nodes to visualize your thought galaxy'}
|
||||||
</MantineText>
|
|
||||||
<MantineText size="sm" c="dimmed">
|
|
||||||
Nodes with content will automatically generate embeddings and 3D coordinates
|
|
||||||
</MantineText>
|
</MantineText>
|
||||||
|
{!emptyMessage && (
|
||||||
|
<MantineText size="sm" c="dimmed">
|
||||||
|
Nodes with content will automatically generate embeddings and 3D coordinates
|
||||||
|
</MantineText>
|
||||||
|
)}
|
||||||
|
{targetUserDid && (
|
||||||
|
<MantineText size="sm" c="dimmed" mt="xs">
|
||||||
|
Viewing galaxy for user: {targetUserDid}
|
||||||
|
</MantineText>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* User info banner when viewing someone else's galaxy */}
|
||||||
|
{targetUserDid && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '10px',
|
||||||
|
right: '10px',
|
||||||
|
zIndex: 999,
|
||||||
|
maxWidth: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper p="sm" radius="md" withBorder shadow="md">
|
||||||
|
<MantineText size="sm" fw={600}>
|
||||||
|
Public Galaxy
|
||||||
|
</MantineText>
|
||||||
|
<MantineText size="xs" c="dimmed">
|
||||||
|
Viewing {nodes.length} public {nodes.length === 1 ? 'node' : 'nodes'}
|
||||||
|
</MantineText>
|
||||||
|
<MantineText size="xs" c="dimmed" style={{ wordBreak: 'break-all' }}>
|
||||||
|
{targetUserDid}
|
||||||
|
</MantineText>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating content overlay for selected node */}
|
{/* Floating content overlay for selected node */}
|
||||||
{selectedNode && (
|
{selectedNode && (
|
||||||
<Box
|
<Box
|
||||||
@@ -317,15 +433,30 @@ export function ThoughtGalaxy() {
|
|||||||
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
|
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
|
||||||
{selectedNode.title}
|
{selectedNode.title}
|
||||||
</Title>
|
</Title>
|
||||||
<Anchor
|
<Group gap="sm" mt="xs">
|
||||||
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
|
<Anchor
|
||||||
target="_blank"
|
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
size="sm"
|
rel="noopener noreferrer"
|
||||||
c="dimmed"
|
size="sm"
|
||||||
>
|
c="dimmed"
|
||||||
View on Bluesky
|
>
|
||||||
</Anchor>
|
View on Bluesky
|
||||||
|
</Anchor>
|
||||||
|
{/* Show delete button only for user's own nodes */}
|
||||||
|
{currentUserDid && selectedNode.user_did === currentUserDid && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconTrash size={14} />}
|
||||||
|
onClick={() => setDeleteConfirmOpen(true)}
|
||||||
|
loading={isDeleting}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
<CloseButton
|
<CloseButton
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -351,6 +482,15 @@ export function ThoughtGalaxy() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
<DeleteNodeModal
|
||||||
|
opened={deleteConfirmOpen}
|
||||||
|
onClose={() => setDeleteConfirmOpen(false)}
|
||||||
|
onConfirm={handleDeleteNode}
|
||||||
|
nodeTitle={selectedNode?.title || null}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 5, 10], fov: 60 }}
|
camera={{ position: [0, 5, 10], fov: 60 }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Menu, Avatar, NavLink, ActionIcon } from '@mantine/core';
|
import { Menu, Avatar, NavLink, ActionIcon, SegmentedControl, Text, Stack, ScrollArea, Code } from '@mantine/core';
|
||||||
|
import { useMantineColorScheme } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { DeleteNodeModal } from './DeleteNodeModal';
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
did: string;
|
did: string;
|
||||||
@@ -11,10 +15,22 @@ interface UserProfile {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
user_did: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
|
const [nodesLoading, setNodesLoading] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch user profile on mount
|
// Fetch user profile on mount
|
||||||
@@ -33,6 +49,66 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch user's nodes for debugging
|
||||||
|
const fetchNodes = async () => {
|
||||||
|
setNodesLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/nodes/debug', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.error) {
|
||||||
|
setNodes(data.nodes || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch nodes:', error);
|
||||||
|
} finally {
|
||||||
|
setNodesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a node (debug) - Matches ThoughtGalaxy delete pattern
|
||||||
|
const handleDebugDelete = async () => {
|
||||||
|
if (!nodeToDelete) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
|
||||||
|
const cleanId = String(nodeToDelete.id).replace(/[⟨⟩]/g, '');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/nodes/${cleanId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to delete node');
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
title: 'Node deleted',
|
||||||
|
message: 'Node has been deleted from Bluesky and your galaxy',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state to remove the deleted node
|
||||||
|
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeToDelete.id));
|
||||||
|
setNodeToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserMenu Debug] Delete error:', error);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Delete failed',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to delete node',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
@@ -129,11 +205,123 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
|||||||
@{profile.handle}
|
@{profile.handle}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Label>
|
</Menu.Label>
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
{/* Theme Selection */}
|
||||||
|
<div style={{ padding: '8px 12px' }}>
|
||||||
|
<Text size="xs" fw={500} c="dimmed" mb={8}>
|
||||||
|
Theme
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={colorScheme}
|
||||||
|
onChange={(value) => setColorScheme(value as 'light' | 'dark' | 'auto')}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: 'light',
|
||||||
|
label: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<IconSun size={16} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark',
|
||||||
|
label: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<IconMoon size={16} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'auto',
|
||||||
|
label: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<IconDeviceDesktop size={16} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fullWidth
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug: Show all nodes */}
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Label>Debug: SurrealDB Nodes</Menu.Label>
|
||||||
|
<div style={{ padding: '8px 12px' }}>
|
||||||
|
<Text size="xs" fw={500} c="dimmed" mb={8}>
|
||||||
|
<button
|
||||||
|
onClick={fetchNodes}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nodesLoading ? 'Loading...' : `Fetch Nodes (${nodes.length})`}
|
||||||
|
</button>
|
||||||
|
</Text>
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<ScrollArea h={200}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<div key={node.id} style={{ fontSize: '0.7rem', marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||||
|
<Text size="xs" fw={600}>{node.title}</Text>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setNodeToDelete(node);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#fa5252',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Code block style={{ fontSize: '0.65rem' }}>{node.id}</Code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
{nodes.length === 0 && !nodesLoading && (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
No nodes found in SurrealDB
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item onClick={handleLogout} c="red">
|
<Menu.Item onClick={handleLogout} c="red">
|
||||||
Log out
|
Log out
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
|
|
||||||
|
{/* Delete confirmation modal - shared with ThoughtGalaxy */}
|
||||||
|
<DeleteNodeModal
|
||||||
|
opened={deleteConfirmOpen}
|
||||||
|
onClose={() => setDeleteConfirmOpen(false)}
|
||||||
|
onConfirm={handleDebugDelete}
|
||||||
|
nodeTitle={nodeToDelete?.title || null}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ DEFINE FIELD coords_3d ON TABLE node TYPE option<array<number>>
|
|||||||
-- Must be NONE or a 3-point array [x, y, z].
|
-- Must be NONE or a 3-point array [x, y, z].
|
||||||
ASSERT $value = NONE OR array::len($value) = 3;
|
ASSERT $value = NONE OR array::len($value) = 3;
|
||||||
|
|
||||||
|
-- Privacy setting: Whether this node is publicly visible.
|
||||||
|
-- Defaults to true (public by default, aligns with ATproto philosophy).
|
||||||
|
-- When false, node is only visible to the owner.
|
||||||
|
DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true;
|
||||||
|
|
||||||
-- Define the vector search index.
|
-- Define the vector search index.
|
||||||
-- We use MTREE (or HNSW) for high-performance k-NN search.
|
-- We use MTREE (or HNSW) for high-performance k-NN search.
|
||||||
-- The dimension (3072) MUST match the output of the
|
-- The dimension (3072) MUST match the output of the
|
||||||
|
|||||||
54
debug-db.mjs
Normal file
54
debug-db.mjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import Surreal from 'surrealdb';
|
||||||
|
|
||||||
|
const USER_DID = 'did:plc:sypdx6a4u2fblmclv6wbxjl3';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new Surreal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Connecting to SurrealDB...');
|
||||||
|
await db.connect('ws://localhost:8000/rpc');
|
||||||
|
|
||||||
|
console.log('Signing in...');
|
||||||
|
await db.signin({
|
||||||
|
username: 'root',
|
||||||
|
password: 'root',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Using namespace/database...');
|
||||||
|
await db.use({
|
||||||
|
namespace: 'ponderants',
|
||||||
|
database: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n===== ALL NODES IN DATABASE =====');
|
||||||
|
const allNodes = await db.query('SELECT * FROM node LIMIT 20');
|
||||||
|
console.log('Total nodes:', allNodes[0]?.length || 0);
|
||||||
|
console.log('Nodes:', JSON.stringify(allNodes[0], null, 2));
|
||||||
|
|
||||||
|
console.log(`\n===== NODES FOR USER ${USER_DID} (WITHOUT coords_3d filter) =====`);
|
||||||
|
const userNodesNoFilter = await db.query(
|
||||||
|
'SELECT id, title, user_did, coords_3d FROM node WHERE user_did = $userDid',
|
||||||
|
{ userDid: USER_DID }
|
||||||
|
);
|
||||||
|
console.log('Count:', userNodesNoFilter[0]?.length || 0);
|
||||||
|
console.log('Nodes:', JSON.stringify(userNodesNoFilter[0], null, 2));
|
||||||
|
|
||||||
|
console.log(`\n===== NODES FOR USER ${USER_DID} (WITH coords_3d != NONE filter) =====`);
|
||||||
|
const userNodesWithFilter = await db.query(
|
||||||
|
'SELECT id, title, user_did, coords_3d FROM node WHERE user_did = $userDid AND coords_3d != NONE',
|
||||||
|
{ userDid: USER_DID }
|
||||||
|
);
|
||||||
|
console.log('Count:', userNodesWithFilter[0]?.length || 0);
|
||||||
|
console.log('Nodes:', JSON.stringify(userNodesWithFilter[0], null, 2));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
console.error('Stack:', error.stack);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
89
docker-compose.ci.yml
Normal file
89
docker-compose.ci.yml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Simplified docker-compose for CI/CD environments
|
||||||
|
# Only includes services needed for testing (excludes surrealmcp)
|
||||||
|
|
||||||
|
services:
|
||||||
|
surrealdb:
|
||||||
|
image: surrealdb/surrealdb:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
command:
|
||||||
|
- start
|
||||||
|
- --log
|
||||||
|
- trace
|
||||||
|
- --user
|
||||||
|
- ${SURREALDB_USER:-root}
|
||||||
|
- --pass
|
||||||
|
- ${SURREALDB_PASS:-root}
|
||||||
|
- memory
|
||||||
|
environment:
|
||||||
|
- SURREAL_LOG=trace
|
||||||
|
|
||||||
|
nextjs:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.next
|
||||||
|
environment:
|
||||||
|
- SURREALDB_URL=ws://surrealdb:8000/rpc
|
||||||
|
- SURREALDB_USER=${SURREALDB_USER:-root}
|
||||||
|
- SURREALDB_PASS=${SURREALDB_PASS:-root}
|
||||||
|
- SURREALDB_NS=${SURREALDB_NS:-ponderants}
|
||||||
|
- SURREALDB_DB=${SURREALDB_DB:-main}
|
||||||
|
- SURREALDB_JWT_SECRET=${SURREALDB_JWT_SECRET}
|
||||||
|
- ATPROTO_CLIENT_ID=${ATPROTO_CLIENT_ID}
|
||||||
|
- ATPROTO_REDIRECT_URI=${ATPROTO_REDIRECT_URI}
|
||||||
|
- GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY}
|
||||||
|
- DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY}
|
||||||
|
- TEST_BLUESKY_HANDLE=${TEST_BLUESKY_HANDLE}
|
||||||
|
- TEST_BLUESKY_PASSWORD=${TEST_BLUESKY_PASSWORD}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- NODE_ENV=development
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
npm install -g pnpm &&
|
||||||
|
pnpm install --frozen-lockfile &&
|
||||||
|
echo 'Waiting for SurrealDB to be ready...' &&
|
||||||
|
sleep 10 &&
|
||||||
|
pnpm dev
|
||||||
|
"
|
||||||
|
depends_on:
|
||||||
|
- surrealdb
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
magnitude:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.56.1-noble
|
||||||
|
working_dir: /app
|
||||||
|
user: root
|
||||||
|
network_mode: "service:nextjs"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- node_modules:/app/node_modules
|
||||||
|
environment:
|
||||||
|
- TEST_BLUESKY_HANDLE=${TEST_BLUESKY_HANDLE}
|
||||||
|
- TEST_BLUESKY_PASSWORD=${TEST_BLUESKY_PASSWORD}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- HOME=/root
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
npm install -g pnpm &&
|
||||||
|
pnpm install --frozen-lockfile &&
|
||||||
|
npx wait-on http://localhost:3000 --timeout 120000 &&
|
||||||
|
xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' npx magnitude
|
||||||
|
"
|
||||||
|
depends_on:
|
||||||
|
nextjs:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles:
|
||||||
|
- test
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
@@ -8,12 +8,50 @@ services:
|
|||||||
- --log
|
- --log
|
||||||
- trace
|
- trace
|
||||||
- --user
|
- --user
|
||||||
- root
|
- ${SURREALDB_USER}
|
||||||
- --pass
|
- --pass
|
||||||
- root
|
- ${SURREALDB_PASS}
|
||||||
- memory
|
- memory
|
||||||
|
volumes:
|
||||||
|
- ./surreal/data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
surrealmcp:
|
||||||
|
image: surrealdb/surrealmcp:latest
|
||||||
|
command: >
|
||||||
|
start
|
||||||
|
--bind-address 0.0.0.0:8080
|
||||||
|
--server-url http://localhost:8080
|
||||||
|
-e ws://surrealdb:8000/rpc
|
||||||
|
--ns ${SURREALDB_NS} --db ${SURREALDB_DB} -u ${SURREALDB_USER} -p ${SURREALDB_PASS}
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- surrealdb
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.56.1-noble
|
||||||
|
working_dir: /home/pwuser/app
|
||||||
|
user: pwuser
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- .:/home/pwuser/app
|
||||||
|
- /home/pwuser/app/node_modules
|
||||||
|
environment:
|
||||||
|
- TEST_BLUESKY_HANDLE=${TEST_BLUESKY_HANDLE}
|
||||||
|
- TEST_BLUESKY_PASSWORD=${TEST_BLUESKY_PASSWORD}
|
||||||
|
- PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-http://localhost:3000}
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
npm install -g pnpm &&
|
||||||
|
pnpm install --frozen-lockfile &&
|
||||||
|
npx playwright test
|
||||||
|
"
|
||||||
|
depends_on:
|
||||||
|
- surrealdb
|
||||||
|
profiles:
|
||||||
|
- test
|
||||||
|
|||||||
171
docs/umap-recalculation-strategy.md
Normal file
171
docs/umap-recalculation-strategy.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# UMAP Recalculation Strategy
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
When creating the 3D thought galaxy visualization, we need to convert high-dimensional AI embeddings (3072 dimensions from `gemini-embedding-001`) into 3D coordinates that can be displayed in the browser.
|
||||||
|
|
||||||
|
### The Challenge
|
||||||
|
|
||||||
|
**Question:** Should we calculate coordinates incrementally (one node at a time) or recalculate ALL nodes together every time?
|
||||||
|
|
||||||
|
**Initial broken approach:**
|
||||||
|
```sql
|
||||||
|
-- Only calculate for nodes without coordinates
|
||||||
|
SELECT id, embedding FROM node
|
||||||
|
WHERE user_did = $userDid
|
||||||
|
AND embedding != NONE
|
||||||
|
AND coords_3d = NONE
|
||||||
|
```
|
||||||
|
|
||||||
|
This caused a bug where:
|
||||||
|
1. Nodes 1-3: Calculate together → ✓ Get coords
|
||||||
|
2. Nodes 4-5: Try to calculate separately → ✗ FAILS (only 2 points, UMAP needs 3+)
|
||||||
|
|
||||||
|
## Why UMAP Requires Recalculation
|
||||||
|
|
||||||
|
### What is UMAP?
|
||||||
|
|
||||||
|
UMAP (Uniform Manifold Approximation and Projection) is a **non-linear manifold learning** algorithm. Unlike linear methods (PCA), UMAP:
|
||||||
|
|
||||||
|
1. **Learns the "shape" (manifold) of your data** - It finds clusters, relationships, and patterns
|
||||||
|
2. **Creates relative, not absolute coordinates** - There's no fixed origin or coordinate system
|
||||||
|
3. **Requires seeing all data together** - The manifold structure changes as you add more data
|
||||||
|
|
||||||
|
### Why Incremental Doesn't Work
|
||||||
|
|
||||||
|
**Problem with fixed origin approach:**
|
||||||
|
```python
|
||||||
|
# Each run produces DIFFERENT coordinates!
|
||||||
|
Run 1: UMAP([node1, node2, node3]) → coords_A
|
||||||
|
Run 2: UMAP([node1, node2, node3]) → coords_B # DIFFERENT!
|
||||||
|
|
||||||
|
# There's no absolute coordinate system
|
||||||
|
Run 1: node1 at [0.5, 0.2, 0.8]
|
||||||
|
Run 2: node1 at [2.1, -1.3, 0.4] # Completely different!
|
||||||
|
```
|
||||||
|
|
||||||
|
The positions are only meaningful **relative to each other**. You can't have a "fixed origin" because UMAP learns a relative manifold structure.
|
||||||
|
|
||||||
|
**Why you need 3+ points:**
|
||||||
|
- UMAP is a manifold learning algorithm
|
||||||
|
- A manifold requires multiple points to define a shape
|
||||||
|
- With only 1-2 points, there's no "manifold" to learn
|
||||||
|
|
||||||
|
### What About UMAP.transform()?
|
||||||
|
|
||||||
|
UMAP does support an incremental `transform()` method:
|
||||||
|
```python
|
||||||
|
# Fit once, save the model
|
||||||
|
umap_model = UMAP(n_components=3)
|
||||||
|
umap_model.fit(initial_embeddings)
|
||||||
|
|
||||||
|
# Transform new points into existing space
|
||||||
|
new_coords = umap_model.transform(new_embedding)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why we're NOT using this:**
|
||||||
|
|
||||||
|
1. **Model storage complexity** - Must store entire UMAP model (includes all training data) in database
|
||||||
|
2. **Model drift** - New nodes get approximate positions based on old manifold structure
|
||||||
|
3. **Loss of quality** - The manifold changes as you add data; transform() doesn't update it
|
||||||
|
4. **Performance** - For <100 nodes, full recalculation is fast (<1 second)
|
||||||
|
|
||||||
|
## Our Solution: Full Recalculation
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Recalculate ALL nodes every time
|
||||||
|
SELECT id, embedding FROM node
|
||||||
|
WHERE user_did = $userDid
|
||||||
|
AND embedding != NONE
|
||||||
|
-- No "coords_3d = NONE" filter!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
When you add a new node:
|
||||||
|
1. Fetch ALL nodes with embeddings (including those with existing coords)
|
||||||
|
2. Run UMAP on the complete dataset
|
||||||
|
3. Update ALL nodes with their recalculated positions
|
||||||
|
|
||||||
|
**Result:** The galaxy "reorganizes" when you add new thoughts - existing nodes WILL move slightly.
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
✅ Always mathematically correct
|
||||||
|
✅ Simple implementation
|
||||||
|
✅ No model storage complexity
|
||||||
|
✅ Best clustering quality (manifold adapts to new data)
|
||||||
|
✅ Fast enough for <100 nodes
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
❌ Galaxy shifts when adding nodes (existing nodes move)
|
||||||
|
❌ O(n²) complexity (slower with many nodes)
|
||||||
|
❌ More database writes
|
||||||
|
|
||||||
|
### Performance Characteristics
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodes | Calculation Time | Acceptable?
|
||||||
|
------|-----------------|------------
|
||||||
|
3 | ~50ms | ✅ Excellent
|
||||||
|
10 | ~200ms | ✅ Great
|
||||||
|
50 | ~800ms | ✅ Good
|
||||||
|
100 | ~1.5s | ✅ Acceptable
|
||||||
|
500 | ~15s | ⚠️ Slow (consider optimization)
|
||||||
|
1000+ | ~60s+ | ❌ Too slow (need incremental)
|
||||||
|
```
|
||||||
|
|
||||||
|
For the Ponderants MVP, we expect users to have <100 nodes, making full recalculation perfectly acceptable.
|
||||||
|
|
||||||
|
## Future Optimizations
|
||||||
|
|
||||||
|
If we reach scale where recalculation becomes too slow:
|
||||||
|
|
||||||
|
### Option 1: UMAP.transform() with Periodic Refitting
|
||||||
|
```typescript
|
||||||
|
// Store UMAP model in database
|
||||||
|
// Transform new nodes incrementally
|
||||||
|
// Every 10 nodes: Refit the entire model
|
||||||
|
if (newNodeCount % 10 === 0) {
|
||||||
|
recalculateAllNodes();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Switch to PCA
|
||||||
|
- PCA is linear and supports incremental updates
|
||||||
|
- Loses UMAP's superior clustering quality
|
||||||
|
- Use for very large datasets (1000+ nodes)
|
||||||
|
|
||||||
|
### Option 3: Hierarchical UMAP
|
||||||
|
- Cluster nodes into groups
|
||||||
|
- Run UMAP on each cluster separately
|
||||||
|
- Use a higher-level UMAP to arrange clusters
|
||||||
|
- Complex but scales to millions of nodes
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
The galaxy "reorganizing" when you add nodes is actually a **feature, not a bug**:
|
||||||
|
|
||||||
|
- It shows your thought network evolving
|
||||||
|
- New connections emerge as you add ideas
|
||||||
|
- Clusters naturally form around related concepts
|
||||||
|
- Creates a sense of a living, breathing knowledge graph
|
||||||
|
|
||||||
|
Users will see their constellation of thoughts naturally reorganize as their ideas grow - which aligns perfectly with the "Ponderants" brand of exploring and structuring ideas.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [UMAP Documentation](https://umap-learn.readthedocs.io/)
|
||||||
|
- [umap-js Library](https://github.com/PAIR-code/umap-js)
|
||||||
|
- [Understanding UMAP](https://pair-code.github.io/understanding-umap/)
|
||||||
|
- [When to use UMAP vs PCA](https://towardsdatascience.com/how-exactly-umap-works-13e3040e1668)
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- **2025-01-10**: Discovered bug where nodes 4-5 failed to get coordinates
|
||||||
|
- **2025-01-10**: Analyzed UMAP manifold learning constraints
|
||||||
|
- **2025-01-10**: Decided to implement full recalculation strategy
|
||||||
|
- **2025-01-10**: Updated `/app/api/calculate-graph/route.ts` to remove `coords_3d = NONE` filter
|
||||||
@@ -6,4 +6,6 @@ export default {
|
|||||||
tests: 'tests/magnitude/**/*.mag.ts',
|
tests: 'tests/magnitude/**/*.mag.ts',
|
||||||
// Run tests in headless mode to avoid window focus issues
|
// Run tests in headless mode to avoid window focus issues
|
||||||
headless: true,
|
headless: true,
|
||||||
|
// Use Claude Sonnet 4.5 for best performance
|
||||||
|
model: 'anthropic:claude-sonnet-4-5-20250929',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"@types/react": "latest",
|
"@types/react": "latest",
|
||||||
"@types/react-dom": "latest",
|
"@types/react-dom": "latest",
|
||||||
"@types/three": "^0.181.0",
|
"@types/three": "^0.181.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "latest",
|
"eslint": "latest",
|
||||||
"eslint-config-next": "latest",
|
"eslint-config-next": "latest",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
|
|||||||
317
plans/10-public-galaxy-viewing.md
Normal file
317
plans/10-public-galaxy-viewing.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# Plan: Make Galaxy Viewable Without Login Requirement
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- **Priority**: HIGH (User-requested)
|
||||||
|
- **Status**: In Progress
|
||||||
|
- **Created**: 2025-01-10
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Currently, the galaxy visualization (`/galaxy`) requires user authentication via JWT cookie. This prevents:
|
||||||
|
1. Public sharing of thought galaxies
|
||||||
|
2. First-time visitors from seeing example galaxies
|
||||||
|
3. Social media link previews from working properly
|
||||||
|
4. Search engines from indexing public thought networks
|
||||||
|
|
||||||
|
The galaxy should be publicly viewable while still respecting user privacy preferences.
|
||||||
|
|
||||||
|
## Current Implementation Analysis
|
||||||
|
|
||||||
|
### Authentication Check
|
||||||
|
`app/api/galaxy/route.ts` (lines 27-38):
|
||||||
|
```typescript
|
||||||
|
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||||
|
|
||||||
|
if (!surrealJwt) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSession = verifySurrealJwt(surrealJwt);
|
||||||
|
if (!userSession) {
|
||||||
|
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Query
|
||||||
|
Currently queries `WHERE user_did = $userDid` (line 49), showing only the authenticated user's nodes.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
### Option 1: Public by Default (RECOMMENDED)
|
||||||
|
**All galaxies are publicly viewable**, but users can mark individual nodes as private.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Simple implementation
|
||||||
|
- Encourages public knowledge sharing
|
||||||
|
- Better for SEO and discovery
|
||||||
|
- Aligns with "decentralized social" vision
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Users might accidentally share private thoughts
|
||||||
|
- Requires clear UI indicators for node visibility
|
||||||
|
|
||||||
|
**URL Structure:**
|
||||||
|
- `/galaxy` - current user's galaxy (if logged in) or landing page
|
||||||
|
- `/galaxy/{user_did}` or `/galaxy?user={user_did}` - specific user's public galaxy
|
||||||
|
|
||||||
|
### Option 2: Opt-in Public Galleries
|
||||||
|
Galaxies are private by default, users must explicitly make them public.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- More privacy-conscious
|
||||||
|
- Users have full control
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Reduces discovery and sharing
|
||||||
|
- More complex implementation
|
||||||
|
- Goes against ATproto's "public by default" philosophy
|
||||||
|
|
||||||
|
**Decision: We'll implement Option 1** - Public by default, with optional private nodes.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Update API to Support Public Access
|
||||||
|
|
||||||
|
#### 1.1 Modify `/api/galaxy/route.ts`
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const targetUserDid = searchParams.get('user');
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||||
|
|
||||||
|
// Determine which user's galaxy to show
|
||||||
|
let userDid: string;
|
||||||
|
let isOwnGalaxy = false;
|
||||||
|
|
||||||
|
if (targetUserDid) {
|
||||||
|
// Viewing someone else's public galaxy
|
||||||
|
userDid = targetUserDid;
|
||||||
|
} else if (surrealJwt) {
|
||||||
|
// Viewing own galaxy (authenticated)
|
||||||
|
const userSession = verifySurrealJwt(surrealJwt);
|
||||||
|
if (!userSession) {
|
||||||
|
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
userDid = userSession.did;
|
||||||
|
isOwnGalaxy = true;
|
||||||
|
} else {
|
||||||
|
// No target user and not authenticated - return empty galaxy with message
|
||||||
|
return NextResponse.json({
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
message: 'Log in to view your galaxy, or visit a public galaxy via ?user={did}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query nodes
|
||||||
|
const nodesQuery = `
|
||||||
|
SELECT id, title, body, user_did, atp_uri, coords_3d
|
||||||
|
FROM node
|
||||||
|
WHERE user_did = $userDid
|
||||||
|
AND coords_3d != NONE
|
||||||
|
${isOwnGalaxy ? '' : 'AND is_public = true'}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ... rest of implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 Add `is_public` Field to Node Schema
|
||||||
|
- Default: `true` (public by default)
|
||||||
|
- Users can mark individual nodes as private
|
||||||
|
- Private nodes are only visible to the owner
|
||||||
|
|
||||||
|
### Phase 2: Update Database Schema
|
||||||
|
|
||||||
|
#### 2.1 Add `is_public` Column to `node` Table
|
||||||
|
```sql
|
||||||
|
DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Create Migration Script
|
||||||
|
`scripts/add-is-public-field.ts`:
|
||||||
|
```typescript
|
||||||
|
import { connectToDB } from '@/lib/db';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const db = await connectToDB();
|
||||||
|
|
||||||
|
// Add field definition
|
||||||
|
await db.query(`
|
||||||
|
DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Set existing nodes to public
|
||||||
|
await db.query(`
|
||||||
|
UPDATE node SET is_public = true WHERE is_public = NONE;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Migration complete: Added is_public field');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Update Frontend Components
|
||||||
|
|
||||||
|
#### 3.1 Update `ThoughtGalaxy.tsx`
|
||||||
|
```typescript
|
||||||
|
// Support viewing other users' galaxies
|
||||||
|
const { searchParams } = useSearchParams();
|
||||||
|
const targetUser = searchParams.get('user');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
const url = targetUser
|
||||||
|
? `/api/galaxy?user=${targetUser}`
|
||||||
|
: '/api/galaxy';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ... rest of implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [targetUser]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Add User Info Display
|
||||||
|
When viewing another user's galaxy, show:
|
||||||
|
- User's Bluesky handle
|
||||||
|
- Link to their profile
|
||||||
|
- Number of public nodes
|
||||||
|
|
||||||
|
#### 3.3 Update `/galaxy` Page
|
||||||
|
Add support for URL parameter: `/galaxy?user=did:plc:xxxxx`
|
||||||
|
|
||||||
|
### Phase 4: Navigation & User Experience
|
||||||
|
|
||||||
|
#### 4.1 Landing Experience for Non-Authenticated Users
|
||||||
|
When visiting `/galaxy` without login:
|
||||||
|
- Show a sample/demo galaxy (could be a curated example)
|
||||||
|
- Display call-to-action: "Create your own thought galaxy"
|
||||||
|
- Provide login button
|
||||||
|
|
||||||
|
#### 4.2 Add "Share Galaxy" Feature
|
||||||
|
Add button to copy shareable link:
|
||||||
|
```typescript
|
||||||
|
const shareUrl = `${window.location.origin}/galaxy?user=${userDid}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Privacy Controls (Future Enhancement)
|
||||||
|
|
||||||
|
#### 5.1 Node-Level Privacy Toggle
|
||||||
|
In node editor, add checkbox:
|
||||||
|
```typescript
|
||||||
|
<Checkbox
|
||||||
|
label="Make this node public"
|
||||||
|
checked={isPublic}
|
||||||
|
onChange={(e) => setIsPublic(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Bulk Privacy Management
|
||||||
|
Settings page to:
|
||||||
|
- Make all nodes private/public
|
||||||
|
- Set default for new nodes
|
||||||
|
- Filter and update specific nodes
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### 1. Data Exposure
|
||||||
|
- **Risk**: Users accidentally share sensitive information
|
||||||
|
- **Mitigation**:
|
||||||
|
- Clear visual indicators for public/private nodes
|
||||||
|
- Confirmation dialog when publishing nodes
|
||||||
|
- Easy way to make nodes private retroactively
|
||||||
|
|
||||||
|
### 2. API Abuse
|
||||||
|
- **Risk**: Scraping or excessive requests to public galaxies
|
||||||
|
- **Mitigation**:
|
||||||
|
- Rate limiting on `/api/galaxy`
|
||||||
|
- Caching layer for public galaxies
|
||||||
|
- Consider CDN for popular galaxies
|
||||||
|
|
||||||
|
### 3. Privacy Violations
|
||||||
|
- **Risk**: Viewing history tracking or surveillance
|
||||||
|
- **Mitigation**:
|
||||||
|
- No analytics on public galaxy views
|
||||||
|
- No "who viewed my galaxy" feature
|
||||||
|
- Respect DNT headers
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Magnitude Tests
|
||||||
|
|
||||||
|
#### Test 1: Public Galaxy Viewing Without Auth
|
||||||
|
```typescript
|
||||||
|
test('Unauthenticated users can view public galaxies', async (agent) => {
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:example123');
|
||||||
|
await agent.check('The galaxy visualization is displayed');
|
||||||
|
await agent.check('Public nodes are visible');
|
||||||
|
await agent.act('Click on a public node');
|
||||||
|
await agent.check('Node details are displayed');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test 2: Private Nodes Hidden from Public View
|
||||||
|
```typescript
|
||||||
|
test('Private nodes are not visible in public galaxy', async (agent) => {
|
||||||
|
// ... implementation
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test 3: Own Galaxy Requires Auth
|
||||||
|
```typescript
|
||||||
|
test('Accessing own galaxy without target user requires authentication', async (agent) => {
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('Login prompt or empty state is displayed');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
- [ ] Visit `/galaxy` without login → see landing page
|
||||||
|
- [ ] Visit `/galaxy?user={valid_did}` → see public nodes
|
||||||
|
- [ ] Visit `/galaxy?user={invalid_did}` → see error message
|
||||||
|
- [ ] Log in and visit `/galaxy` → see own galaxy (including private nodes)
|
||||||
|
- [ ] Share galaxy link → recipient can view public nodes
|
||||||
|
- [ ] Mark node as private → confirm it disappears from public view
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Create database migration** for `is_public` field
|
||||||
|
2. **Update API route** to support public access
|
||||||
|
3. **Update ThoughtGalaxy component** to handle URL parameters
|
||||||
|
4. **Add user info display** for public galaxies
|
||||||
|
5. **Test with manual checks**
|
||||||
|
6. **Write Magnitude tests**
|
||||||
|
7. **Update documentation**
|
||||||
|
8. **Create PR with changes**
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
✅ Unauthenticated users can view public galaxies via `?user=` parameter
|
||||||
|
✅ Authenticated users see their own galaxy at `/galaxy` (no param)
|
||||||
|
✅ Private nodes are only visible to the owner
|
||||||
|
✅ Public nodes are visible to everyone
|
||||||
|
✅ Clear error messages for invalid user DIDs
|
||||||
|
✅ Shareable URLs work correctly
|
||||||
|
✅ All tests pass
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This aligns with ATproto's philosophy of public-by-default, user-controlled data
|
||||||
|
- Future enhancement: Node-level privacy controls in UI
|
||||||
|
- Consider adding Open Graph meta tags for social media previews
|
||||||
|
- May want to add a "featured galaxies" page for discovery
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `app/api/galaxy/route.ts` - Galaxy API endpoint
|
||||||
|
- `components/ThoughtGalaxy.tsx` - 3D visualization component
|
||||||
|
- `app/galaxy/page.tsx` - Galaxy page component
|
||||||
|
- `lib/db/schema.surql` - Database schema
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/playwright',
|
testDir: './tests/playwright',
|
||||||
@@ -12,6 +16,7 @@ export default defineConfig({
|
|||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
headless: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -105,6 +105,9 @@ importers:
|
|||||||
'@types/three':
|
'@types/three':
|
||||||
specifier: ^0.181.0
|
specifier: ^0.181.0
|
||||||
version: 0.181.0
|
version: 0.181.0
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.2.3
|
||||||
|
version: 17.2.3
|
||||||
eslint:
|
eslint:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 9.39.1(jiti@2.6.1)
|
version: 9.39.1(jiti@2.6.1)
|
||||||
@@ -1710,6 +1713,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dotenv@17.2.3:
|
||||||
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
@@ -5034,6 +5041,8 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
|
|||||||
85
scripts/README.md
Normal file
85
scripts/README.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Development Scripts
|
||||||
|
|
||||||
|
## test-ci-locally.sh
|
||||||
|
|
||||||
|
Tests the CI workflow locally by running the **exact same docker-compose command** that the Gitea Actions workflow runs.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
When CI tests fail, this script reproduces the exact CI environment locally to debug issues without repeatedly pushing to trigger CI runs. It runs `docker-compose.ci.yml` with the same parameters as the CI workflow, so you're testing in an identical environment.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-ci-locally.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run docker-compose directly (this is what the script does):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.ci.yml --profile test up \
|
||||||
|
--abort-on-container-exit \
|
||||||
|
--exit-code-from magnitude
|
||||||
|
```
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
1. Checks that `.env` file exists
|
||||||
|
2. Runs `docker compose -f docker-compose.ci.yml --profile test up`
|
||||||
|
3. This starts all services:
|
||||||
|
- **surrealdb**: In-memory database with health check
|
||||||
|
- **nextjs**: Node.js container running `pnpm dev` with health check
|
||||||
|
- **magnitude**: Playwright container running the test suite
|
||||||
|
4. Waits for tests to complete
|
||||||
|
5. Exits with magnitude's exit code
|
||||||
|
6. Shows service logs on failure
|
||||||
|
7. Cleans up containers and volumes
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Docker and docker-compose installed
|
||||||
|
- `.env` file with test credentials
|
||||||
|
|
||||||
|
### Services Architecture
|
||||||
|
|
||||||
|
The script starts a containerized test environment with proper health checks and dependencies:
|
||||||
|
|
||||||
|
```
|
||||||
|
magnitude (Playwright container - runs tests)
|
||||||
|
↓ depends on (waits for health check)
|
||||||
|
nextjs (Node.js container - runs pnpm dev)
|
||||||
|
↓ depends on (waits for health check)
|
||||||
|
surrealdb (SurrealDB container - in-memory mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
All services share the same network:
|
||||||
|
- Next.js accesses SurrealDB via `ws://surrealdb:8000/rpc`
|
||||||
|
- Magnitude accesses Next.js via `http://localhost:3000`
|
||||||
|
|
||||||
|
### Why This Approach?
|
||||||
|
|
||||||
|
This is simpler and more accurate than using workflow runner tools like `act` or `act_runner` because:
|
||||||
|
|
||||||
|
1. **Identical to CI**: The CI workflow (`.gitea/workflows/magnitude.yml`) literally runs this docker-compose command, so you're testing the exact same thing
|
||||||
|
2. **No Additional Tools**: Doesn't require `act`, `act_runner`, or any workflow execution tools
|
||||||
|
3. **Direct Debugging**: Runs the actual test commands directly, making it easier to see what's happening
|
||||||
|
4. **Faster**: No overhead from workflow interpretation or runner setup
|
||||||
|
|
||||||
|
### Debugging CI Failures
|
||||||
|
|
||||||
|
If Gitea Actions fail:
|
||||||
|
|
||||||
|
1. Check the workflow logs for errors in Gitea UI
|
||||||
|
2. Run `./scripts/test-ci-locally.sh` to reproduce **exactly**
|
||||||
|
3. The script will show the same output as CI
|
||||||
|
4. Debug with docker-compose logs if needed:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.ci.yml logs surrealdb
|
||||||
|
docker compose -f docker-compose.ci.yml logs nextjs
|
||||||
|
docker compose -f docker-compose.ci.yml logs magnitude
|
||||||
|
```
|
||||||
|
5. Fix issues locally
|
||||||
|
6. Run script again to verify fix
|
||||||
|
7. Commit and push once tests pass locally
|
||||||
|
|
||||||
|
This is **much** faster than debugging via CI push cycles and gives you identical results!
|
||||||
44
scripts/add-is-public-field.ts
Normal file
44
scripts/add-is-public-field.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Surreal from 'surrealdb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Add is_public field to node table
|
||||||
|
*
|
||||||
|
* This script adds the is_public field to all existing nodes,
|
||||||
|
* defaulting them to public (true) to align with the public-by-default
|
||||||
|
* philosophy of ATproto.
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
const db = new Surreal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.connect('ws://localhost:8000/rpc');
|
||||||
|
await db.signin({ username: 'root', password: 'root' });
|
||||||
|
await db.use({ namespace: 'ponderants', database: 'main' });
|
||||||
|
|
||||||
|
console.log('Adding is_public field definition to node table...');
|
||||||
|
|
||||||
|
// Define the field (should already be in schema.surql, but ensuring it's applied)
|
||||||
|
await db.query(`
|
||||||
|
DEFINE FIELD is_public ON TABLE node TYPE bool DEFAULT true;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✓ Field definition added');
|
||||||
|
|
||||||
|
console.log('Setting existing nodes to public...');
|
||||||
|
|
||||||
|
// Set all existing nodes where is_public is NONE to true (public by default)
|
||||||
|
const result = await db.query(`
|
||||||
|
UPDATE node SET is_public = true WHERE is_public = NONE;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Result:', result);
|
||||||
|
console.log('✓ Migration complete: All existing nodes are now public by default');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
91
scripts/recalculate-all-coords.ts
Normal file
91
scripts/recalculate-all-coords.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import Surreal from 'surrealdb';
|
||||||
|
import { UMAP } from 'umap-js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate 3D coordinates for ALL nodes
|
||||||
|
*
|
||||||
|
* This script fixes the issue where new nodes don't get coordinates
|
||||||
|
* because UMAP needs to see the full dataset to properly position points.
|
||||||
|
*
|
||||||
|
* Usage: tsx scripts/recalculate-all-coords.ts
|
||||||
|
*/
|
||||||
|
async function recalculateAllCoordinates() {
|
||||||
|
const db = new Surreal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to production database
|
||||||
|
const dbUrl = process.env.SURREALDB_URL || 'wss://ponderants-prod-06d6iecp19qj3bvmv2o0r5j50o.aws-usw2.surreal.cloud/rpc';
|
||||||
|
const dbNs = process.env.SURREALDB_NS || 'ponderants';
|
||||||
|
const dbName = process.env.SURREALDB_DB || 'production';
|
||||||
|
const dbUser = process.env.SURREALDB_USER || 'root';
|
||||||
|
const dbPass = process.env.SURREALDB_PASS;
|
||||||
|
|
||||||
|
if (!dbPass) {
|
||||||
|
throw new Error('SURREALDB_PASS environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Connecting to ${dbUrl}...`);
|
||||||
|
await db.connect(dbUrl);
|
||||||
|
await db.signin({ username: dbUser, password: dbPass });
|
||||||
|
await db.use({ namespace: dbNs, database: dbName });
|
||||||
|
console.log('✓ Connected to database');
|
||||||
|
|
||||||
|
// Fetch ALL nodes with embeddings (not just those without coords)
|
||||||
|
console.log('Fetching all nodes with embeddings...');
|
||||||
|
const results = await db.query<[Array<{ id: string; embedding: number[] }>]>(
|
||||||
|
'SELECT id, title, embedding FROM node WHERE embedding != NONE'
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodes = results[0] || [];
|
||||||
|
console.log(`Found ${nodes.length} nodes with embeddings`);
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
console.log('No nodes with embeddings found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length < 3) {
|
||||||
|
console.error(`ERROR: Need at least 3 nodes for UMAP, found ${nodes.length}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run UMAP on ALL nodes together
|
||||||
|
const embeddings = nodes.map((n) => n.embedding);
|
||||||
|
|
||||||
|
console.log('Running UMAP dimensionality reduction...');
|
||||||
|
console.log(`- Input: ${nodes.length} nodes with ${embeddings[0].length}-dimensional embeddings`);
|
||||||
|
console.log(`- Output: 3D coordinates`);
|
||||||
|
|
||||||
|
const umap = new UMAP({
|
||||||
|
nComponents: 3,
|
||||||
|
nNeighbors: Math.min(15, nodes.length - 1), // nNeighbors must be < sample size
|
||||||
|
minDist: 0.1,
|
||||||
|
spread: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const coords_3d_array = await umap.fitAsync(embeddings);
|
||||||
|
console.log('✓ UMAP projection complete');
|
||||||
|
|
||||||
|
// Update ALL nodes with their new 3D coords
|
||||||
|
console.log('Updating nodes with new coordinates...');
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const node = nodes[i];
|
||||||
|
const coords = coords_3d_array[i];
|
||||||
|
|
||||||
|
await db.merge(node.id, {
|
||||||
|
coords_3d: [coords[0], coords[1], coords[2]],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Updated ${node.id}: [${coords[0].toFixed(3)}, ${coords[1].toFixed(3)}, ${coords[2].toFixed(3)}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Successfully updated ${nodes.length} nodes with 3D coordinates`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recalculateAllCoordinates();
|
||||||
62
scripts/test-ci-locally.sh
Executable file
62
scripts/test-ci-locally.sh
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to test CI workflow locally by running the exact same docker-compose command as CI
|
||||||
|
# This runs docker-compose.ci.yml which is what the Gitea Actions workflow uses
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "Testing CI Workflow Locally"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo -e "${RED}Error: .env file not found!${NC}"
|
||||||
|
echo "Please create .env file with required variables"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Running the exact same docker-compose command as CI${NC}"
|
||||||
|
echo -e "${YELLOW}This executes: docker compose -f docker-compose.ci.yml --profile test up${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
echo -e "${YELLOW}Cleaning up containers and volumes...${NC}"
|
||||||
|
docker compose -f docker-compose.ci.yml down -v
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trap cleanup on exit
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Run the exact same command that CI runs
|
||||||
|
docker compose -f docker-compose.ci.yml --profile test up \
|
||||||
|
--abort-on-container-exit \
|
||||||
|
--exit-code-from magnitude || {
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}=========================================${NC}"
|
||||||
|
echo -e "${RED}Tests failed!${NC}"
|
||||||
|
echo -e "${RED}=========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Showing service logs:${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "=== SurrealDB Logs ==="
|
||||||
|
docker compose -f docker-compose.ci.yml logs --tail=50 surrealdb
|
||||||
|
echo ""
|
||||||
|
echo "=== Next.js Logs ==="
|
||||||
|
docker compose -f docker-compose.ci.yml logs --tail=50 nextjs
|
||||||
|
echo ""
|
||||||
|
echo "=== Magnitude Logs ==="
|
||||||
|
docker compose -f docker-compose.ci.yml logs --tail=50 magnitude
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=========================================${NC}"
|
||||||
|
echo -e "${GREEN}All tests passed!${NC}"
|
||||||
|
echo -e "${GREEN}=========================================${NC}"
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { test } from 'magnitude-test';
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
test('Application boots and displays homepage', async (agent) => {
|
test('Application boots and displays login page', async (agent) => {
|
||||||
// Act: Navigate to the homepage (uses the default URL
|
// Act: Navigate to the root URL (should redirect to /login)
|
||||||
// from magnitude.config.ts)
|
await agent.act('Navigate to http://localhost:3000');
|
||||||
await agent.act('Navigate to the homepage');
|
|
||||||
|
|
||||||
// Check: Verify that the homepage text is visible
|
// Check: Verify the login page loads with expected elements
|
||||||
// This confirms the Next.js app is serving content.
|
await agent.check('The text "Ponderants" or "Log in with Bluesky" is visible on the screen');
|
||||||
await agent.check('The text "Ponderants" is visible on the screen');
|
await agent.check('A login form or button is displayed');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,214 @@
|
|||||||
import { test } from 'magnitude-test';
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
test('Mantine theme is applied correctly', async (agent) => {
|
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||||
// Act: Navigate to the homepage
|
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||||
await agent.act('Navigate to the homepage');
|
|
||||||
|
|
||||||
// Check: Verify the Mantine components are rendered
|
if (!TEST_HANDLE || !TEST_PASSWORD) {
|
||||||
await agent.check('The text "Ponderants" is visible as a title');
|
throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env');
|
||||||
await agent.check('A "Test Button" is visible on the screen');
|
}
|
||||||
|
|
||||||
// Check: Verify the theme is applied.
|
test('Theme selector in profile dropdown has three options', async (agent) => {
|
||||||
// We check that the page uses a dark background with grayscale styling
|
// Act: Log in first (theme selector is in authenticated area)
|
||||||
await agent.check(
|
await agent.act('Navigate to /login');
|
||||||
'The page has a dark background with light text, consistent with a grayscale dark theme'
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
);
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
// Check: Verify the Paper component is rendered with its themed styles
|
// Act: Open the profile dropdown menu
|
||||||
await agent.check(
|
await agent.act('Click on the profile avatar or "Profile" button');
|
||||||
'The page content is inside a "Paper" component with a border'
|
|
||||||
);
|
// Check: Verify the theme selector is visible with three options
|
||||||
|
await agent.check('A "Theme" label is visible in the dropdown menu');
|
||||||
|
await agent.check('A segmented control with three icon buttons is visible');
|
||||||
|
await agent.check('A sun icon button is visible (for light mode)');
|
||||||
|
await agent.check('A moon icon button is visible (for dark mode)');
|
||||||
|
await agent.check('A desktop/monitor icon button is visible (for system/auto mode)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Theme can be changed between light, dark, and auto modes', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Open profile dropdown and select light mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the sun icon in the theme selector');
|
||||||
|
|
||||||
|
// Check: Verify light mode is active
|
||||||
|
await agent.check('The page has a light background color');
|
||||||
|
await agent.check('The sun icon button appears selected/highlighted');
|
||||||
|
|
||||||
|
// Act: Switch to dark mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the moon icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Check: Verify dark mode is active
|
||||||
|
await agent.check('The page has a dark background color');
|
||||||
|
await agent.check('The moon icon button appears selected/highlighted');
|
||||||
|
|
||||||
|
// Act: Switch to auto/system mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the desktop icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Check: Verify auto mode is selected
|
||||||
|
await agent.check('The desktop icon button appears selected/highlighted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Light mode displays correct colors', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Set to light mode via profile dropdown
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the sun icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Check: Verify light mode colors
|
||||||
|
await agent.check('The page has a light background color');
|
||||||
|
await agent.check('The sidebar has a light background');
|
||||||
|
await agent.check('The text is dark colored for readability');
|
||||||
|
await agent.check('The borders are subtle and light-colored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dark mode displays correct colors', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Set to dark mode via profile dropdown
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the moon icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Check: Verify dark mode colors
|
||||||
|
await agent.check('The page has a dark background color');
|
||||||
|
await agent.check('The sidebar has a dark background');
|
||||||
|
await agent.check('The text is light colored for readability');
|
||||||
|
await agent.check('The borders are dark-colored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Theme persists across page refreshes', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Set to light mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the sun icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Act: Refresh the page
|
||||||
|
await agent.act('Refresh the page');
|
||||||
|
await agent.act('Wait for the page to load');
|
||||||
|
|
||||||
|
// Check: Verify light mode persisted
|
||||||
|
await agent.check('The page still has a light background color');
|
||||||
|
|
||||||
|
// Act: Switch to dark mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the moon icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Act: Refresh the page again
|
||||||
|
await agent.act('Refresh the page');
|
||||||
|
await agent.act('Wait for the page to load');
|
||||||
|
|
||||||
|
// Check: Verify dark mode persisted
|
||||||
|
await agent.check('The page still has a dark background color');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Theme affects all UI components', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Set to light mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the sun icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Check: Verify all components use light theme
|
||||||
|
await agent.check('The navigation sidebar uses light colors');
|
||||||
|
await agent.check('The main content area uses light colors');
|
||||||
|
await agent.check('All buttons and inputs use light theme styling');
|
||||||
|
|
||||||
|
// Act: Switch to dark mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the moon icon in the theme selector');
|
||||||
|
await agent.act('Wait for 500 milliseconds');
|
||||||
|
|
||||||
|
// Check: Verify all components use dark theme
|
||||||
|
await agent.check('The navigation sidebar uses dark colors');
|
||||||
|
await agent.check('The main content area uses dark colors');
|
||||||
|
await agent.check('All buttons and inputs use dark theme styling');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Theme selector correctly indicates selected theme', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Set to light mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the sun icon in the theme selector');
|
||||||
|
|
||||||
|
// Check: Verify sun icon is highlighted/selected
|
||||||
|
await agent.check('The sun icon button appears selected or highlighted');
|
||||||
|
|
||||||
|
// Act: Switch to dark mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the moon icon in the theme selector');
|
||||||
|
|
||||||
|
// Check: Verify moon icon is highlighted/selected
|
||||||
|
await agent.check('The moon icon button appears selected or highlighted');
|
||||||
|
|
||||||
|
// Act: Switch to auto mode
|
||||||
|
await agent.act('Click on the profile avatar');
|
||||||
|
await agent.act('Click the desktop icon in the theme selector');
|
||||||
|
|
||||||
|
// Check: Verify desktop icon is highlighted/selected
|
||||||
|
await agent.check('The desktop icon button appears selected or highlighted');
|
||||||
});
|
});
|
||||||
|
|||||||
204
tests/magnitude/03-delete-node.mag.ts
Normal file
204
tests/magnitude/03-delete-node.mag.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||||
|
|
||||||
|
if (!TEST_HANDLE || !TEST_PASSWORD) {
|
||||||
|
throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('User can delete their own node from galaxy view', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Create a test node via chat
|
||||||
|
await agent.act('Type "This is a test node for deletion" into the chat input');
|
||||||
|
await agent.act('Press Enter or click send');
|
||||||
|
await agent.check('AI responds with a message');
|
||||||
|
|
||||||
|
// Act: Trigger node creation
|
||||||
|
await agent.act('Wait for the AI to suggest creating a node or manually trigger node creation');
|
||||||
|
await agent.check('A node draft is created in the editor');
|
||||||
|
|
||||||
|
// Act: Publish the node
|
||||||
|
await agent.act('Click the "Publish" button');
|
||||||
|
await agent.check('A success notification appears');
|
||||||
|
await agent.check('The node is published to Bluesky');
|
||||||
|
|
||||||
|
// Act: Navigate to Galaxy view
|
||||||
|
await agent.act('Click the "Galaxy" navigation link');
|
||||||
|
await agent.check('The galaxy visualization loads');
|
||||||
|
await agent.check('At least one node is visible in the 3D galaxy view');
|
||||||
|
|
||||||
|
// Act: Click on the newly created node
|
||||||
|
await agent.act('Click on the test node in the galaxy view');
|
||||||
|
await agent.check('A node detail panel opens showing the node title and body');
|
||||||
|
await agent.check('The node detail panel shows "This is a test node for deletion"');
|
||||||
|
|
||||||
|
// Check: Verify delete button is visible (only for user\'s own nodes)
|
||||||
|
await agent.check('A "Delete" button is visible in the node detail panel');
|
||||||
|
|
||||||
|
// Act: Click the delete button
|
||||||
|
await agent.act('Click the "Delete" button');
|
||||||
|
|
||||||
|
// Check: Verify delete confirmation modal appears
|
||||||
|
await agent.check('A delete confirmation modal appears');
|
||||||
|
await agent.check('The modal is displayed above the node detail panel');
|
||||||
|
await agent.check('The modal shows "Are you sure you want to delete this node?"');
|
||||||
|
await agent.check('The modal explains this will remove the post from Bluesky');
|
||||||
|
await agent.check('The modal has a "Delete Permanently" button');
|
||||||
|
await agent.check('The modal has a "Cancel" button');
|
||||||
|
|
||||||
|
// Act: Confirm deletion
|
||||||
|
await agent.act('Click the "Delete Permanently" button');
|
||||||
|
|
||||||
|
// Check: Verify deletion succeeded
|
||||||
|
await agent.check('A success notification appears saying "Node deleted"');
|
||||||
|
await agent.check('The node detail panel closes');
|
||||||
|
await agent.check('The node is no longer visible in the galaxy view');
|
||||||
|
|
||||||
|
// Act: Verify node is deleted from Bluesky
|
||||||
|
await agent.act('Navigate to the user\'s Bluesky profile');
|
||||||
|
await agent.check('The test node "This is a test node for deletion" is not visible on Bluesky');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delete button is not shown for other users\' nodes', async (agent) => {
|
||||||
|
// This test would require viewing another user's public galaxy
|
||||||
|
// Skipping for now as it requires a second test account
|
||||||
|
await agent.act('Skip this test - requires second test account');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cancel button closes delete confirmation without deleting', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Navigate to Galaxy view
|
||||||
|
await agent.act('Click the "Galaxy" navigation link');
|
||||||
|
await agent.check('The galaxy visualization loads');
|
||||||
|
|
||||||
|
// Act: Click on any existing node
|
||||||
|
await agent.act('Click on any node in the galaxy view');
|
||||||
|
await agent.check('A node detail panel opens');
|
||||||
|
|
||||||
|
// Act: Click the delete button
|
||||||
|
await agent.act('Click the "Delete" button');
|
||||||
|
await agent.check('A delete confirmation modal appears');
|
||||||
|
|
||||||
|
// Act: Click cancel
|
||||||
|
await agent.act('Click the "Cancel" button');
|
||||||
|
|
||||||
|
// Check: Verify modal closes and node is still there
|
||||||
|
await agent.check('The delete confirmation modal closes');
|
||||||
|
await agent.check('The node detail panel is still open');
|
||||||
|
await agent.check('The node is still visible in the galaxy view');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Node deletion removes associated links', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Create two linked nodes
|
||||||
|
await agent.act('Create a first test node via chat');
|
||||||
|
await agent.act('Create a second test node that links to the first');
|
||||||
|
|
||||||
|
// Act: Navigate to Galaxy view
|
||||||
|
await agent.act('Click the "Galaxy" navigation link');
|
||||||
|
await agent.check('The galaxy visualization shows two nodes with a link between them');
|
||||||
|
|
||||||
|
// Act: Delete one of the nodes
|
||||||
|
await agent.act('Click on the first test node');
|
||||||
|
await agent.act('Click the "Delete" button');
|
||||||
|
await agent.act('Click "Delete Permanently"');
|
||||||
|
|
||||||
|
// Check: Verify the link is also removed
|
||||||
|
await agent.check('The link between the nodes is no longer visible');
|
||||||
|
await agent.check('Only one node remains in the galaxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User can delete node from debug panel in Profile menu', async (agent) => {
|
||||||
|
// Act: Log in
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
|
||||||
|
// Act: Create a test node via chat
|
||||||
|
await agent.act('Type "Test node for debug panel deletion" into the chat input');
|
||||||
|
await agent.act('Press Enter or click send');
|
||||||
|
await agent.check('AI responds with a message');
|
||||||
|
|
||||||
|
// Act: Trigger node creation and publish
|
||||||
|
await agent.act('Wait for the AI to suggest creating a node or manually trigger node creation');
|
||||||
|
await agent.check('A node draft is created in the editor');
|
||||||
|
await agent.act('Click the "Publish" button');
|
||||||
|
await agent.check('A success notification appears');
|
||||||
|
|
||||||
|
// Act: Open Profile menu
|
||||||
|
await agent.act('Click the "Profile" button in the navigation sidebar');
|
||||||
|
await agent.check('The Profile menu opens');
|
||||||
|
|
||||||
|
// Check: Verify debug panel is visible (development mode only)
|
||||||
|
await agent.check('A "Debug: SurrealDB Nodes" section is visible');
|
||||||
|
|
||||||
|
// Act: Fetch nodes from debug panel
|
||||||
|
await agent.act('Click the "Fetch Nodes" button');
|
||||||
|
await agent.check('The button shows a count greater than 0');
|
||||||
|
await agent.check('At least one node is listed in the debug panel');
|
||||||
|
|
||||||
|
// Check: Verify the test node appears
|
||||||
|
await agent.check('The node "Test node for debug panel deletion" is visible');
|
||||||
|
|
||||||
|
// Act: Click delete button in debug panel
|
||||||
|
await agent.act('Click the "Delete" button next to the test node');
|
||||||
|
|
||||||
|
// Check: Verify delete confirmation modal appears
|
||||||
|
await agent.check('A delete confirmation modal appears');
|
||||||
|
await agent.check('The modal is displayed above the profile menu');
|
||||||
|
await agent.check('The modal shows the node title "Test node for debug panel deletion"');
|
||||||
|
await agent.check('The modal explains this will remove the post from Bluesky');
|
||||||
|
await agent.check('The modal shows "This action cannot be undone"');
|
||||||
|
await agent.check('The modal has a "Delete Permanently" button');
|
||||||
|
await agent.check('The modal has a "Cancel" button');
|
||||||
|
|
||||||
|
// Act: Confirm deletion
|
||||||
|
await agent.act('Click the "Delete Permanently" button');
|
||||||
|
|
||||||
|
// Check: Verify deletion succeeded
|
||||||
|
await agent.check('A success notification appears saying "Node deleted"');
|
||||||
|
await agent.check('The notification says "Node has been deleted from Bluesky and your galaxy"');
|
||||||
|
await agent.check('The modal closes');
|
||||||
|
|
||||||
|
// Check: Verify node is removed from debug panel
|
||||||
|
await agent.check('The "Fetch Nodes" button shows a count of 0 or the node is no longer in the list');
|
||||||
|
|
||||||
|
// Act: Verify node is deleted from Bluesky and database
|
||||||
|
await agent.act('Refresh the page');
|
||||||
|
await agent.act('Click the "Profile" button again');
|
||||||
|
await agent.act('Click the "Fetch Nodes" button');
|
||||||
|
await agent.check('The node "Test node for debug panel deletion" is not in the list');
|
||||||
|
});
|
||||||
133
tests/magnitude/03-public-galaxy.mag.ts
Normal file
133
tests/magnitude/03-public-galaxy.mag.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Galaxy Viewing Tests
|
||||||
|
*
|
||||||
|
* These tests verify that:
|
||||||
|
* 1. Galaxies can be viewed publicly via ?user={did} parameter
|
||||||
|
* 2. Private nodes are hidden from public view
|
||||||
|
* 3. Own galaxy requires authentication
|
||||||
|
* 4. Invalid user DIDs are handled gracefully
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('Unauthenticated users can view public galaxies via user parameter', async (agent) => {
|
||||||
|
// Navigate to a public galaxy using the user parameter
|
||||||
|
// Note: This test assumes there's at least one user with public nodes
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:example123');
|
||||||
|
|
||||||
|
// Check: The galaxy visualization should be displayed
|
||||||
|
await agent.check('The 3D galaxy visualization is visible');
|
||||||
|
|
||||||
|
// Check: Public nodes should be visible
|
||||||
|
await agent.check('At least one node sphere is visible in the 3D space');
|
||||||
|
|
||||||
|
// Check: A "Public Galaxy" banner should be shown
|
||||||
|
await agent.check('A banner or indicator shows this is a public galaxy');
|
||||||
|
|
||||||
|
// Check: Can interact with nodes
|
||||||
|
await agent.act('Click on a visible node sphere');
|
||||||
|
await agent.check('A modal or panel displays the node title and content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Private nodes are not visible in public galaxy view', async (agent) => {
|
||||||
|
// This test requires a user with both public and private nodes
|
||||||
|
// Navigate to their public galaxy
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:user-with-private-nodes');
|
||||||
|
|
||||||
|
// Check: Only public nodes are visible
|
||||||
|
await agent.check('The galaxy shows only public nodes');
|
||||||
|
|
||||||
|
// Check: Private nodes are not visible in the visualization
|
||||||
|
await agent.check('Private nodes are not rendered in the 3D space');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Accessing own galaxy without user parameter shows authenticated galaxy', async (agent) => {
|
||||||
|
// First, log in
|
||||||
|
await agent.act('Navigate to /');
|
||||||
|
await agent.act('Click the login button');
|
||||||
|
await agent.act('Complete the Bluesky OAuth flow');
|
||||||
|
|
||||||
|
// Navigate to /galaxy without user parameter
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
|
||||||
|
// Check: Should see own galaxy (including private nodes)
|
||||||
|
await agent.check('The galaxy visualization shows all nodes including private ones');
|
||||||
|
|
||||||
|
// Check: No "Public Galaxy" banner should be shown (it's your own galaxy)
|
||||||
|
await agent.check('There is no "Public Galaxy" banner visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Accessing own galaxy without authentication shows empty state', async (agent) => {
|
||||||
|
// Ensure user is logged out
|
||||||
|
await agent.act('Navigate to /');
|
||||||
|
await agent.act('If logged in, click logout');
|
||||||
|
|
||||||
|
// Navigate to /galaxy without user parameter
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
|
||||||
|
// Check: Should see a message about logging in or viewing a public galaxy
|
||||||
|
await agent.check('A message is displayed about logging in or visiting a public galaxy');
|
||||||
|
|
||||||
|
// Check: No nodes are visible
|
||||||
|
await agent.check('No node spheres are visible in the visualization');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invalid user DID shows appropriate error or empty state', async (agent) => {
|
||||||
|
// Navigate to galaxy with an invalid/non-existent user DID
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:invalid-nonexistent-user');
|
||||||
|
|
||||||
|
// Check: Should handle gracefully (empty state or error message)
|
||||||
|
await agent.check('An appropriate message is shown for invalid or empty galaxy');
|
||||||
|
|
||||||
|
// Check: No crash or loading spinner stuck
|
||||||
|
await agent.check('The page is in a stable state (not stuck loading)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Public galaxy displays user information', async (agent) => {
|
||||||
|
// Navigate to a public galaxy
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:example123');
|
||||||
|
|
||||||
|
// Check: User DID or handle is displayed
|
||||||
|
await agent.check('The user DID is visible in the UI');
|
||||||
|
|
||||||
|
// Check: Node count is displayed
|
||||||
|
await agent.check('The number of public nodes is shown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Public galaxy node details show Bluesky link', async (agent) => {
|
||||||
|
// Navigate to a public galaxy
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:example123');
|
||||||
|
|
||||||
|
// Act: Click on a node
|
||||||
|
await agent.act('Click on a visible node sphere');
|
||||||
|
|
||||||
|
// Check: Node details modal is shown
|
||||||
|
await agent.check('A modal displays the node title and body');
|
||||||
|
|
||||||
|
// Check: "View on Bluesky" link is present
|
||||||
|
await agent.check('A "View on Bluesky" link is visible');
|
||||||
|
|
||||||
|
// Check: Link points to Bluesky
|
||||||
|
await agent.check('The link URL includes "bsky.app"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can switch between own galaxy and public galaxy', async (agent) => {
|
||||||
|
// Log in
|
||||||
|
await agent.act('Navigate to /');
|
||||||
|
await agent.act('Click the login button');
|
||||||
|
await agent.act('Complete the Bluesky OAuth flow');
|
||||||
|
|
||||||
|
// View own galaxy
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('Own galaxy is displayed');
|
||||||
|
|
||||||
|
// Navigate to someone else's public galaxy
|
||||||
|
await agent.act('Navigate to /galaxy?user=did:plc:other-user');
|
||||||
|
await agent.check('The "Public Galaxy" banner is shown');
|
||||||
|
await agent.check('A different set of nodes is visible');
|
||||||
|
|
||||||
|
// Navigate back to own galaxy
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('Own galaxy is displayed again');
|
||||||
|
await agent.check('No "Public Galaxy" banner is shown');
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { test } from 'magnitude-test';
|
|||||||
|
|
||||||
test('[Happy Path] User can have a full voice conversation with AI', async (agent) => {
|
test('[Happy Path] User can have a full voice conversation with AI', async (agent) => {
|
||||||
// Act: Navigate to chat page (assumes user is already authenticated)
|
// Act: Navigate to chat page (assumes user is already authenticated)
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Check: Initial state - voice button shows "Start Voice Conversation"
|
// Check: Initial state - voice button shows "Start Voice Conversation"
|
||||||
await agent.check('A button with text "Start Voice Conversation" is visible');
|
await agent.check('A button with text "Start Voice Conversation" is visible');
|
||||||
@@ -76,7 +76,7 @@ test('[Happy Path] User can have a full voice conversation with AI', async (agen
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('[Unhappy Path] Voice mode handles errors gracefully', async (agent) => {
|
test('[Unhappy Path] Voice mode handles errors gracefully', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Act: Start voice mode
|
// Act: Start voice mode
|
||||||
await agent.act('Click the "Start Voice Conversation" button');
|
await agent.act('Click the "Start Voice Conversation" button');
|
||||||
@@ -93,7 +93,7 @@ test('[Unhappy Path] Voice mode handles errors gracefully', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('[Happy Path] Text input is disabled during voice mode', async (agent) => {
|
test('[Happy Path] Text input is disabled during voice mode', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Check: Text input is enabled initially
|
// Check: Text input is enabled initially
|
||||||
await agent.check('The text input field "Or type your thoughts here..." is enabled');
|
await agent.check('The text input field "Or type your thoughts here..." is enabled');
|
||||||
@@ -112,7 +112,7 @@ test('[Happy Path] Text input is disabled during voice mode', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('[Happy Path] User can type a message while voice mode is idle', async (agent) => {
|
test('[Happy Path] User can type a message while voice mode is idle', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Act: Type a message in the text input
|
// Act: Type a message in the text input
|
||||||
await agent.act('Type "This is a text message" into the text input field');
|
await agent.act('Type "This is a text message" into the text input field');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { test } from 'magnitude-test';
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
test('Node publishes successfully with cache (no warnings)', async (agent) => {
|
test('Node publishes successfully with cache (no warnings)', async (agent) => {
|
||||||
await agent.open('http://localhost:3000');
|
await agent.act('Navigate to http://localhost:3000');
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
await agent.act('Click the "Log in with Bluesky" button');
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
|||||||
59
tests/magnitude/helpers.ts
Normal file
59
tests/magnitude/helpers.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Reusable test helpers for Magnitude tests
|
||||||
|
*
|
||||||
|
* These helpers encapsulate common test patterns to reduce code duplication
|
||||||
|
* and make tests more maintainable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||||
|
|
||||||
|
if (!TEST_HANDLE || !TEST_PASSWORD) {
|
||||||
|
throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs complete OAuth login flow
|
||||||
|
*
|
||||||
|
* This function navigates to the login page and completes the full OAuth flow:
|
||||||
|
* 1. Navigate to /login
|
||||||
|
* 2. Enter handle and click "Log in with Bluesky"
|
||||||
|
* 3. Wait for redirect to Bluesky OAuth page
|
||||||
|
* 4. Enter password and click "Sign in"
|
||||||
|
* 5. Click "Authorize" button
|
||||||
|
* 6. Wait for redirect to /chat
|
||||||
|
*
|
||||||
|
* @param agent - The Magnitude test agent
|
||||||
|
*/
|
||||||
|
export async function loginFlow(agent: any) {
|
||||||
|
// Navigate to login page
|
||||||
|
await agent.act('Navigate to /login');
|
||||||
|
|
||||||
|
// Fill in handle and initiate OAuth
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||||
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
|
|
||||||
|
// Wait for redirect to Bluesky OAuth page
|
||||||
|
await agent.check('The page URL contains "bsky.social"');
|
||||||
|
|
||||||
|
// Fill in credentials on Bluesky OAuth page
|
||||||
|
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||||
|
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||||
|
|
||||||
|
// Submit login form
|
||||||
|
await agent.act('Click the submit/authorize button');
|
||||||
|
|
||||||
|
// Wait for and click authorize button
|
||||||
|
await agent.act('Click the "Authorize" button');
|
||||||
|
|
||||||
|
// Verify successful login
|
||||||
|
await agent.check('The page URL contains "/chat"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test credentials for use in tests that need them directly
|
||||||
|
*/
|
||||||
|
export const TEST_CREDENTIALS = {
|
||||||
|
handle: TEST_HANDLE,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
} as const;
|
||||||
@@ -12,7 +12,7 @@ import { test } from 'magnitude-test';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
test('User can publish a node from conversation', async (agent) => {
|
test('User can publish a node from conversation', async (agent) => {
|
||||||
await agent.open('http://localhost:3000');
|
await agent.act('Navigate to http://localhost:3000');
|
||||||
|
|
||||||
// Step 1: Login with Bluesky
|
// Step 1: Login with Bluesky
|
||||||
await agent.act('Click the "Log in with Bluesky" button');
|
await agent.act('Click the "Log in with Bluesky" button');
|
||||||
@@ -48,7 +48,7 @@ test('User can publish a node from conversation', async (agent) => {
|
|||||||
|
|
||||||
test('User can edit node draft before publishing', async (agent) => {
|
test('User can edit node draft before publishing', async (agent) => {
|
||||||
// Assumes user is already logged in from previous test
|
// Assumes user is already logged in from previous test
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Start conversation
|
// Start conversation
|
||||||
await agent.act('Type "Testing the edit flow" and press Enter');
|
await agent.act('Type "Testing the edit flow" and press Enter');
|
||||||
@@ -71,7 +71,7 @@ test('User can edit node draft before publishing', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('User can cancel node draft without publishing', async (agent) => {
|
test('User can cancel node draft without publishing', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Start conversation
|
// Start conversation
|
||||||
await agent.act('Type "Test cancellation" and press Enter');
|
await agent.act('Type "Test cancellation" and press Enter');
|
||||||
@@ -93,7 +93,7 @@ test('User can cancel node draft without publishing', async (agent) => {
|
|||||||
|
|
||||||
test('Cannot publish node without authentication', async (agent) => {
|
test('Cannot publish node without authentication', async (agent) => {
|
||||||
// Open edit page directly without being logged in
|
// Open edit page directly without being logged in
|
||||||
await agent.open('http://localhost:3000/edit');
|
await agent.act('Navigate to http://localhost:3000/edit');
|
||||||
|
|
||||||
await agent.check('Shows empty state message');
|
await agent.check('Shows empty state message');
|
||||||
await agent.check('Message says "No Node Draft"');
|
await agent.check('Message says "No Node Draft"');
|
||||||
@@ -101,7 +101,7 @@ test('Cannot publish node without authentication', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Cannot publish node with empty title', async (agent) => {
|
test('Cannot publish node with empty title', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Create draft
|
// Create draft
|
||||||
await agent.act('Type "Test empty title validation" and press Enter');
|
await agent.act('Type "Test empty title validation" and press Enter');
|
||||||
@@ -116,7 +116,7 @@ test('Cannot publish node with empty title', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Cannot publish node with empty content', async (agent) => {
|
test('Cannot publish node with empty content', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Create draft
|
// Create draft
|
||||||
await agent.act('Type "Test empty content validation" and press Enter');
|
await agent.act('Type "Test empty content validation" and press Enter');
|
||||||
@@ -131,7 +131,7 @@ test('Cannot publish node with empty content', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Shows error notification if publish fails', async (agent) => {
|
test('Shows error notification if publish fails', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Create draft
|
// Create draft
|
||||||
await agent.act('Type "Test error handling" and press Enter');
|
await agent.act('Type "Test error handling" and press Enter');
|
||||||
@@ -149,7 +149,7 @@ test('Shows error notification if publish fails', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Handles long content with truncation', async (agent) => {
|
test('Handles long content with truncation', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
// Create a very long message
|
// Create a very long message
|
||||||
const longMessage = 'A'.repeat(500) + ' This is a test of long content truncation for Bluesky posts.';
|
const longMessage = 'A'.repeat(500) + ' This is a test of long content truncation for Bluesky posts.';
|
||||||
@@ -168,7 +168,7 @@ test('Handles long content with truncation', async (agent) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Shows warning when cache fails but publish succeeds', async (agent) => {
|
test('Shows warning when cache fails but publish succeeds', async (agent) => {
|
||||||
await agent.open('http://localhost:3000/chat');
|
await agent.act('Navigate to http://localhost:3000/chat');
|
||||||
|
|
||||||
await agent.act('Type "Test cache failure graceful degradation" and press Enter');
|
await agent.act('Type "Test cache failure graceful degradation" and press Enter');
|
||||||
await agent.check('AI responds');
|
await agent.check('AI responds');
|
||||||
@@ -190,7 +190,7 @@ test('Shows warning when cache fails but publish succeeds', async (agent) => {
|
|||||||
|
|
||||||
test('Complete user journey: Login → Converse → Publish → View', async (agent) => {
|
test('Complete user journey: Login → Converse → Publish → View', async (agent) => {
|
||||||
// Full end-to-end test
|
// Full end-to-end test
|
||||||
await agent.open('http://localhost:3000');
|
await agent.act('Navigate to http://localhost:3000');
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
await agent.act('Login with Bluesky')
|
await agent.act('Login with Bluesky')
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { test as setup, expect } from '@playwright/test';
|
import { test as setup } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { performOAuthLogin } from './helpers';
|
||||||
|
|
||||||
const authFile = 'tests/playwright/.auth/user.json';
|
const authFile = 'tests/playwright/.auth/user.json';
|
||||||
|
|
||||||
setup('authenticate', async ({ page }) => {
|
setup('authenticate', async ({ page }) => {
|
||||||
// For now, just create an empty auth file
|
console.log('[Auth Setup] Starting OAuth authentication flow');
|
||||||
// TODO: Implement actual OAuth flow when test credentials are available
|
|
||||||
console.log('[Auth Setup] Skipping authentication - implement OAuth flow with test credentials');
|
|
||||||
|
|
||||||
// Save empty state for now
|
// Clear any existing auth state file to ensure fresh login
|
||||||
|
if (fs.existsSync(authFile)) {
|
||||||
|
fs.unlinkSync(authFile);
|
||||||
|
console.log('[Auth Setup] Cleared existing auth state');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform OAuth login using reusable helper
|
||||||
|
await performOAuthLogin(page);
|
||||||
|
|
||||||
|
// Ensure the auth directory exists
|
||||||
|
const authDir = path.dirname(authFile);
|
||||||
|
if (!fs.existsSync(authDir)) {
|
||||||
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save authenticated state
|
||||||
await page.context().storageState({ path: authFile });
|
await page.context().storageState({ path: authFile });
|
||||||
|
console.log(`[Auth Setup] Saved authentication state to ${authFile}`);
|
||||||
});
|
});
|
||||||
|
|||||||
78
tests/playwright/helpers.ts
Normal file
78
tests/playwright/helpers.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Reusable test helpers for Playwright tests
|
||||||
|
*
|
||||||
|
* These helpers encapsulate common test patterns to reduce code duplication
|
||||||
|
* and make tests more maintainable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||||
|
|
||||||
|
if (!TEST_HANDLE || !TEST_PASSWORD) {
|
||||||
|
throw new Error(
|
||||||
|
'TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env file'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs complete OAuth login flow
|
||||||
|
*
|
||||||
|
* This function navigates to the login page and completes the full OAuth flow:
|
||||||
|
* 1. Navigate to /login
|
||||||
|
* 2. Enter handle and click "Log in with Bluesky"
|
||||||
|
* 3. Wait for redirect to Bluesky OAuth page
|
||||||
|
* 4. Enter password and click "Sign in"
|
||||||
|
* 5. Click "Authorize" button
|
||||||
|
* 6. Wait for redirect to /chat
|
||||||
|
* 7. Verify authentication successful
|
||||||
|
*
|
||||||
|
* @param page - The Playwright Page object
|
||||||
|
*/
|
||||||
|
export async function performOAuthLogin(page: Page) {
|
||||||
|
console.log('[Helper] Starting OAuth login flow');
|
||||||
|
|
||||||
|
// Navigate to login page
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
// Fill in handle and initiate OAuth
|
||||||
|
await page.getByLabel('Your Handle').fill(TEST_HANDLE!);
|
||||||
|
|
||||||
|
// Click button and wait for navigation to Bluesky OAuth page
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/bsky.social/**', { timeout: 30000 }),
|
||||||
|
page.getByRole('button', { name: 'Log in with Bluesky' }).click(),
|
||||||
|
]);
|
||||||
|
console.log('[Helper] Redirected to Bluesky OAuth page');
|
||||||
|
|
||||||
|
// The identifier is pre-filled from our login flow, just fill in password
|
||||||
|
// Use getByRole to avoid strict mode violations with multiple "Password" labeled elements
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill(TEST_PASSWORD!);
|
||||||
|
|
||||||
|
// Click Sign in button
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Wait for the OAuth authorization page by looking for the Authorize button
|
||||||
|
await page.getByRole('button', { name: 'Authorize' }).waitFor({ timeout: 10000 });
|
||||||
|
console.log('[Helper] On OAuth authorization page');
|
||||||
|
|
||||||
|
// Click Authorize button to grant access and wait for redirect
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/chat', { timeout: 20000 }),
|
||||||
|
page.getByRole('button', { name: 'Authorize' }).click(),
|
||||||
|
]);
|
||||||
|
console.log('[Helper] Successfully authorized, redirected to /chat');
|
||||||
|
|
||||||
|
// Verify we're actually logged in by checking for Profile nav link
|
||||||
|
await expect(page.getByText('Profile')).toBeVisible({ timeout: 5000 });
|
||||||
|
console.log('[Helper] Verified authentication successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test credentials for use in tests that need them directly
|
||||||
|
*/
|
||||||
|
export const TEST_CREDENTIALS = {
|
||||||
|
handle: TEST_HANDLE,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
} as const;
|
||||||
7
tests/playwright/seed.spec.ts
Normal file
7
tests/playwright/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.
|
||||||
|
});
|
||||||
|
});
|
||||||
14
todo.md
14
todo.md
@@ -6,11 +6,15 @@ Upcoming items that should be implemented (time-permitting):
|
|||||||
playwright mcp testing as well as that of magnitude
|
playwright mcp testing as well as that of magnitude
|
||||||
- ADD MAGNITUDE TESTS FOR EVERYTHING, both existing and new additions
|
- ADD MAGNITUDE TESTS FOR EVERYTHING, both existing and new additions
|
||||||
- stream the AI output to deepgram for faster synthesis
|
- stream the AI output to deepgram for faster synthesis
|
||||||
- fix the freaking galaxy node clicking -- when going directly to a node ID
|
- dark mode/light mode favicon
|
||||||
link, it redirects to /chat; when clicking on a node in /galaxy (either
|
|
||||||
general or on a specific node ID url there), it closes the modal automatically
|
|
||||||
- dark mode/light mode favicon and overall app theme
|
|
||||||
- fix the double border on desktop between sidebar and conversation actions UI
|
- fix the double border on desktop between sidebar and conversation actions UI
|
||||||
- delete "backup"/"old" page.tsx files
|
- delete "backup"/"old" page.tsx files
|
||||||
- allow ai to transition to edit in chat
|
- allow ai to transition to edit in chat
|
||||||
- why wait for three nodes before umap?
|
- fix creation/display of node links
|
||||||
|
- render markdown
|
||||||
|
- fix the "new tables being created instead of adding to the proper table"
|
||||||
|
issues we're having with the other tables like we were having with the node
|
||||||
|
table and we're now having with at least the oauth session, oauth state, and
|
||||||
|
user tabless; it's probably happening with the link_to table as well but that
|
||||||
|
one doesn't have data because it seems like link creation is broken (see task
|
||||||
|
above to fix)
|
||||||
|
|||||||
Reference in New Issue
Block a user