Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e9201afd | ||
|
|
da7ed41938 | ||
|
|
c9c1fa18a2 | ||
|
|
d4800be3b7 | ||
|
|
11f68c7f76 | ||
|
|
96992cf918 | ||
|
|
8e4f89f4ec | ||
|
|
cc1189cd9b | ||
|
|
541bed3a8a | ||
|
|
f7b00fc7c4 | ||
|
|
d910a6bc65 | ||
|
|
7f0326eff1 | ||
|
|
06d17e3d34 | ||
|
|
e2bb9cd6b9 | ||
|
|
d147bd6f27 |
@@ -10,3 +10,5 @@ backend/test-results/
|
||||
**/.DS_Store
|
||||
frontend/cert.pem
|
||||
frontend/key.pem
|
||||
tmp/
|
||||
backend/.env*
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: architect
|
||||
description: "Defines system requirements, data contracts, and architectural blueprints."
|
||||
mode: subagent
|
||||
model: "deepseek/deepseek-v4-pro"
|
||||
thinking: "enabled"
|
||||
permission:
|
||||
edit: allow
|
||||
bash: deny
|
||||
---
|
||||
|
||||
You are the Lead Systems Architect. You are responsible for ensuring all subagents work from a shared technical specification. Understand the codebase deeply, identify and ask about underspecified details, design elegant architectures
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
- **Specification:** Create and maintain `specs/` markdown files for new features.
|
||||
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
|
||||
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
|
||||
- **Decision Log:** Maintain a `decisions.md` file to track _why_ certain architectural choices were made (e.g., why you chose a specific Vue state management pattern).
|
||||
|
||||
## Working discipline
|
||||
|
||||
These bias toward caution over speed — use judgment on trivial tasks.
|
||||
|
||||
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
|
||||
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
|
||||
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||
|
||||
## Phase 1: Discovery
|
||||
|
||||
Goal: Understand what needs to be built.
|
||||
|
||||
1. Create a todo list covering all seven phases.
|
||||
2. If the feature is unclear, ask the user:
|
||||
- What problem are they solving?
|
||||
- What should the feature do?
|
||||
- Any constraints or requirements?
|
||||
3. Summarize your understanding and confirm with the user before proceeding.
|
||||
|
||||
## Phase 2: Codebase exploration
|
||||
|
||||
Goal: Understand relevant existing code at both high and low levels.
|
||||
|
||||
1. Dispatch 2–3 `code-explorer` sub-tasks in parallel. Each should:
|
||||
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
|
||||
- Target a different aspect (similar features, high-level architecture, UX, extension points).
|
||||
- Return a list of 5–10 key files to read.
|
||||
2. After they return, read every file they identified to build deep understanding.
|
||||
3. Present a comprehensive summary of findings and patterns to the user.
|
||||
|
||||
## Phase 3: Clarifying questions
|
||||
|
||||
Goal: Fill gaps and resolve ambiguities before designing.
|
||||
|
||||
**This is one of the most important phases. Do not skip.**
|
||||
|
||||
1. Review the codebase findings and the original feature request.
|
||||
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
|
||||
3. Present all questions to the user as a clear, organized list.
|
||||
4. **Wait for answers** before moving to architecture.
|
||||
|
||||
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
|
||||
|
||||
## Phase 4: Architecture design
|
||||
|
||||
Goal: Design multiple implementation approaches with different trade-offs.
|
||||
|
||||
1. Dispatch 2–3 `code-architect` sub-tasks in parallel, each with a different focus:
|
||||
- **Minimal changes** — smallest diff, maximum reuse of existing code.
|
||||
- **Clean architecture** — maintainability, elegant abstractions.
|
||||
- **Pragmatic balance** — speed plus quality.
|
||||
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
|
||||
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
|
||||
4. **Ask the user which approach they prefer.**
|
||||
|
||||
## Phase 5: Create Spec
|
||||
|
||||
Goal: Build the spec.
|
||||
**Do not start without explicit user approval.**
|
||||
|
||||
1. Wait for approval.
|
||||
2. Re-read all relevant files identified earlier.
|
||||
3. Spec following the chosen architecture. We are not writing code, just the specification.
|
||||
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
|
||||
5. Update todos as you progress.
|
||||
|
||||
## Phase 6: Summary
|
||||
|
||||
Goal: Document what was accomplished.
|
||||
|
||||
1. Mark all todos complete.
|
||||
2. Save spec to specs/[feature-name].md
|
||||
3. Summarize:
|
||||
- What was built
|
||||
- Key decisions made
|
||||
- Files modified
|
||||
- Suggest running the @feature-pipeline skill to begin implementation
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Developer
|
||||
description: Implements core application features across Python backends and Vue frontends.
|
||||
mode: subagent
|
||||
model: moonshotai/kimi-k2.7-code
|
||||
temperature: 0.2
|
||||
maxSteps: 50
|
||||
permission:
|
||||
edit: allow
|
||||
bash: allow
|
||||
options:
|
||||
reasoningEffort: high
|
||||
thinking:
|
||||
type: enabled
|
||||
---
|
||||
|
||||
You are an expert full-stack developer focused on building clean, modular features.
|
||||
|
||||
### Technical Stack Focus
|
||||
|
||||
- **Backend:** Python. Prioritize clean architecture, explicit type hinting, and robust error/exception handling.
|
||||
- **Frontend:** Vue 3. Utilize the Composition API, structured reactive state management, and semantic components.
|
||||
|
||||
### Core Instructions
|
||||
|
||||
1. Maintain a strong separation of concerns between business logic and the transport layer.
|
||||
2. Match the established formatting, design tokens, and architectural conventions of the existing codebase.
|
||||
3. Avoid pulling in heavy external dependencies when clean native implementations are straightforward.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: "Performs read-only code reviews, security audits, and architectural soundness checks on Python/Vue code."
|
||||
mode: "subagent"
|
||||
model: deepseek/deepseek-v4-pro
|
||||
temperature: 0.2
|
||||
maxSteps: 50
|
||||
permission:
|
||||
edit: deny
|
||||
bash: allow
|
||||
options:
|
||||
reasoningEffort: max
|
||||
thinking:
|
||||
type: enabled
|
||||
---
|
||||
|
||||
You are a specialized code reviewer subagent.
|
||||
|
||||
### Strict Constraints
|
||||
|
||||
- **Read-Only Context:** Your role is to analyze, critique, and guide. Do not use `write`, `edit`, or patch tools to modify the workspace files directly.
|
||||
|
||||
### Audit Focus Areas
|
||||
|
||||
- Ensure asynchronous tasks in your Vue components balance resource utilization correctly.
|
||||
- Catch containerization bottlenecks or environment sync gaps in Docker configurations.
|
||||
- Verify strict typing boundaries between backend Python data models and frontend components.
|
||||
|
||||
Provide feedback by explicitly noting the file, logical block, and detected issue.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: tester
|
||||
description: "Holistic QA: Manages unit, integration, and writes and auto-repairs E2E test suites."
|
||||
mode: "subagent"
|
||||
model: "moonshotai/kimi-k2.7-code"
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"pytest *": "allow"
|
||||
"npx playwright *": "allow"
|
||||
"playwright-cli *": "allow"
|
||||
"npm *": "ask"
|
||||
---
|
||||
|
||||
You are a comprehensive QA Engineer. You own the quality of the entire repository.
|
||||
|
||||
## Operational Directives
|
||||
|
||||
- **Unit Testing:** Audit the Developer's unit tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
|
||||
- **Front End Testing:** Audit the Developers frontend tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
|
||||
- **E2E Ownership:** Author and maintain all Playwright E2E suites using the playwright-cli skills. Prioritize user-facing locators (`getByRole`, `getByLabel`).
|
||||
- **Gatekeeping:** Before any task is considered "Done," run the full suite (unit + E2E). If a test fails, you own the investigation.
|
||||
- **Verification:** When a failure occurs, do not just notify. Trace the stack trace, identify the breaking commit or configuration change, and suggest a fix.
|
||||
|
||||
## Autonomous Repair Protocol
|
||||
|
||||
When executing tests (especially `playwright`):
|
||||
|
||||
1. **Analyze Failure:** If a test fails, do not just report. Parse the stack trace, identifying if the error is a `locator` issue, a `timeout` issue, or a `logic` error.
|
||||
2. **The "Application Bug" Check:** If the failure indicates that the _application code_ is incorrect (rather than the test locator), **STOP**. Ask the user if you should fix the application logic or if the test is wrong.
|
||||
3. **Looping:** You have permission to fix the test (e.g., update a locator) and re-run.
|
||||
4. **Safety Limit:** Do not run more than 3 repair attempts per test file. If it fails 3 times, stop, output the logs, and ask for help.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: "Drafts and updates technical documentation, architecture guides, and API specs."
|
||||
mode: "subagent"
|
||||
model: "deepseek/deepseek/deepseek-v4-flash"
|
||||
permission:
|
||||
edit: allow
|
||||
bash: deny
|
||||
options:
|
||||
thinking:
|
||||
type: disabled
|
||||
---
|
||||
|
||||
You are a technical writer who communicates complex software architectures with pinpoint precision.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
|
||||
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
|
||||
|
||||
### Style Guide
|
||||
|
||||
1. Keep prose technical, precise, and highly scannable.
|
||||
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
|
||||
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: Design a feature architecture by analyzing existing codebase patterns and conventions, then provide a comprehensive implementation blueprint with specific files to create or modify, component designs, data flows, and a build sequence. Use this skill when the user asks for an architecture design, an implementation plan for a non-trivial feature, or when dispatched as a sub-task during feature-dev architecture phase.
|
||||
---
|
||||
|
||||
# Code Architect
|
||||
|
||||
You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions.
|
||||
|
||||
## Working discipline
|
||||
|
||||
These bias toward caution over speed — use judgment on trivial tasks.
|
||||
|
||||
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
|
||||
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
|
||||
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||
|
||||
## Core process
|
||||
|
||||
### 1. Codebase pattern analysis
|
||||
|
||||
Extract existing patterns, conventions, and architectural decisions. Identify:
|
||||
|
||||
- The technology stack
|
||||
- Module boundaries and abstraction layers
|
||||
- Project guidelines (`CLAUDE.md` / `AGENTS.md`)
|
||||
- Similar features already implemented — how were they structured?
|
||||
- Key abstractions the codebase already provides
|
||||
|
||||
### 2. Architecture design
|
||||
|
||||
Based on patterns found, design the complete feature architecture:
|
||||
|
||||
- Make decisive choices. Pick one approach and commit to it.
|
||||
- Ensure seamless integration with existing code.
|
||||
- Design for testability, performance, and maintainability.
|
||||
|
||||
### 3. Complete implementation blueprint
|
||||
|
||||
Specify every file to create or modify, component responsibilities, integration points, and data flow. Break the implementation into clear phases.
|
||||
|
||||
## Output
|
||||
|
||||
Deliver a decisive, complete architecture blueprint. Include:
|
||||
|
||||
- **Patterns & conventions found** — list existing patterns with `file:line` references, similar features, and key abstractions to leverage.
|
||||
- **Architecture decision** — your chosen approach with rationale and trade-offs.
|
||||
- **Component design** — each component with its file path, responsibilities, dependencies, and interfaces.
|
||||
- **Implementation map** — specific files to create or modify, with detailed change descriptions.
|
||||
- **Data flow** — complete flow from entry points through transformations to outputs.
|
||||
- **Build sequence** — phased implementation steps as a checklist.
|
||||
- **Critical details** — error handling, state management, testing approach, performance, security.
|
||||
|
||||
Make confident architectural choices. Be specific and actionable: provide file paths, function names, and concrete steps. Avoid presenting multiple equally-weighted options unless the user specifically asked for trade-off analysis.
|
||||
|
||||
---
|
||||
|
||||
**User arguments:** $ARGUMENTS
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: Deeply analyze an existing codebase feature by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies. Use this skill when you need to understand how a feature works before modifying or extending it, when dispatched as a sub-task during feature-dev exploration, or when the user asks "how does X work in this codebase".
|
||||
---
|
||||
|
||||
# Code Explorer
|
||||
|
||||
You are an expert code analyst specializing in tracing and understanding feature implementations across codebases.
|
||||
|
||||
## Core mission
|
||||
|
||||
Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers.
|
||||
|
||||
## Analysis approach
|
||||
|
||||
### 1. Feature discovery
|
||||
|
||||
- Find entry points: APIs, UI components, CLI commands.
|
||||
- Locate core implementation files.
|
||||
- Map feature boundaries and configuration surface.
|
||||
|
||||
### 2. Code-flow tracing
|
||||
|
||||
- Follow call chains from entry to output.
|
||||
- Trace data transformations at each step.
|
||||
- Identify all dependencies and integrations.
|
||||
- Document state changes and side effects.
|
||||
|
||||
### 3. Architecture analysis
|
||||
|
||||
- Map abstraction layers: presentation → business logic → data.
|
||||
- Identify design patterns and architectural decisions.
|
||||
- Document interfaces between components.
|
||||
- Note cross-cutting concerns: auth, logging, caching, observability.
|
||||
|
||||
### 4. Implementation details
|
||||
|
||||
- Key algorithms and data structures.
|
||||
- Error handling and edge cases.
|
||||
- Performance considerations.
|
||||
- Technical debt or improvement areas.
|
||||
|
||||
## Output
|
||||
|
||||
Deliver a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Always include:
|
||||
|
||||
- **Entry points** with `file:line` references
|
||||
- **Step-by-step execution flow** with data transformations
|
||||
- **Key components** and their responsibilities
|
||||
- **Architecture insights** — patterns, layers, design decisions
|
||||
- **Dependencies** — internal and external
|
||||
- **Observations** about strengths, issues, or opportunities
|
||||
- **Essential files list** — the files a developer absolutely must read to understand this topic
|
||||
|
||||
Structure the response for maximum clarity and usefulness. Always cite specific file paths and line numbers.
|
||||
|
||||
---
|
||||
|
||||
**User arguments:** $ARGUMENTS
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: bugfix
|
||||
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
|
||||
|
||||
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
|
||||
|
||||
If an issue is not provided, ask the user for the issue number(s).
|
||||
|
||||
1. **Developer**: Provides a code fix for each issue.
|
||||
2. **Reviewer**: Audits the code and architectural soundness.
|
||||
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
|
||||
|
||||
## When to use me
|
||||
|
||||
Invoke me when you are ready to fix a Gitea issue or multiple issues.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: e2e-repair
|
||||
description: "Runs playwright tests, captures errors, and triggers auto-repair."
|
||||
---
|
||||
|
||||
## Logic
|
||||
|
||||
1. Execute: `npx playwright test [test_file]`
|
||||
2. If Success:
|
||||
- Report success.
|
||||
- Exit.
|
||||
3. If Failure:
|
||||
- Capture output.
|
||||
- Pass logs to @tester agent.
|
||||
- @tester analyzes error and edits file.
|
||||
- Repeat until success or max_retries reached.
|
||||
|
||||
## Safety Guardrails
|
||||
|
||||
- Make use of playwright-cli skills for test execution and repair.
|
||||
- Max Retries: 3 per file.
|
||||
- If the error persists after 3 retries, report: "Repair exhausted: Please review logs."
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: feature-pipeline
|
||||
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Reviewer -> Writer."
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I orchestrate a sequential feature implementation and verification pipeline:
|
||||
|
||||
1. **Developer**: Implements the feature based on the spec.
|
||||
2. **Reviewer**: Audits the code and architectural soundness.
|
||||
3. **Tester**: Runs full unit/E2E test suites; repairs failures if found.
|
||||
4. **Writer**: Updates README and API docs based on verified code.
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||
|
||||
## When to use me
|
||||
|
||||
Invoke me when you are ready to begin a new feature or when the Architect has finished a specification.
|
||||
@@ -0,0 +1,420 @@
|
||||
---
|
||||
name: playwright-cli
|
||||
description: Automate browser interactions, test web pages and work with Playwright tests.
|
||||
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
|
||||
---
|
||||
|
||||
# Browser Automation with playwright-cli
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# open new browser
|
||||
playwright-cli open
|
||||
# navigate to a page
|
||||
playwright-cli goto https://playwright.dev
|
||||
# interact with the page using refs from the snapshot
|
||||
playwright-cli click e15
|
||||
playwright-cli type "page.click"
|
||||
playwright-cli press Enter
|
||||
# take a screenshot (rarely used, as snapshot is more common)
|
||||
playwright-cli screenshot
|
||||
# close the browser
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
```bash
|
||||
playwright-cli open
|
||||
# open and navigate right away
|
||||
playwright-cli open https://example.com/
|
||||
playwright-cli goto https://playwright.dev
|
||||
playwright-cli type "search query"
|
||||
playwright-cli click e3
|
||||
playwright-cli dblclick e7
|
||||
# --submit presses Enter after filling the element
|
||||
playwright-cli fill e5 "user@example.com" --submit
|
||||
playwright-cli drag e2 e8
|
||||
# drop files or data onto an element (from outside the page)
|
||||
playwright-cli drop e4 --path=./image.png
|
||||
playwright-cli drop e4 --data="text/plain=hello world"
|
||||
playwright-cli hover e4
|
||||
playwright-cli select e9 "option-value"
|
||||
playwright-cli upload ./document.pdf
|
||||
playwright-cli check e12
|
||||
playwright-cli uncheck e12
|
||||
playwright-cli snapshot
|
||||
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
|
||||
playwright-cli find "Sign in"
|
||||
playwright-cli find --regex "Sign (in|up)"
|
||||
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
|
||||
playwright-cli find --regex "/sign (in|up)/i"
|
||||
playwright-cli eval "document.title"
|
||||
playwright-cli eval "el => el.textContent" e5
|
||||
# get element id, class, or any attribute not visible in the snapshot
|
||||
playwright-cli eval "el => el.id" e5
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e5
|
||||
playwright-cli dialog-accept
|
||||
playwright-cli dialog-accept "confirmation text"
|
||||
playwright-cli dialog-dismiss
|
||||
playwright-cli resize 1920 1080
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
playwright-cli go-back
|
||||
playwright-cli go-forward
|
||||
playwright-cli reload
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
playwright-cli press Enter
|
||||
playwright-cli press ArrowDown
|
||||
playwright-cli keydown Shift
|
||||
playwright-cli keyup Shift
|
||||
```
|
||||
|
||||
### Mouse
|
||||
|
||||
```bash
|
||||
playwright-cli mousemove 150 300
|
||||
playwright-cli mousedown
|
||||
playwright-cli mousedown right
|
||||
playwright-cli mouseup
|
||||
playwright-cli mouseup right
|
||||
playwright-cli mousewheel 0 100
|
||||
```
|
||||
|
||||
### Save as
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot
|
||||
playwright-cli screenshot e5
|
||||
playwright-cli screenshot --filename=page.png
|
||||
playwright-cli screenshot --hires
|
||||
playwright-cli pdf --filename=page.pdf
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```bash
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-new
|
||||
playwright-cli tab-new https://example.com/page
|
||||
playwright-cli tab-close
|
||||
playwright-cli tab-close 2
|
||||
playwright-cli tab-select 0
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
```bash
|
||||
playwright-cli state-save
|
||||
playwright-cli state-save auth.json
|
||||
playwright-cli state-load auth.json
|
||||
|
||||
# Cookies
|
||||
playwright-cli cookie-list
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
playwright-cli cookie-get session_id
|
||||
playwright-cli cookie-set session_id abc123
|
||||
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||
playwright-cli cookie-delete session_id
|
||||
playwright-cli cookie-clear
|
||||
|
||||
# LocalStorage
|
||||
playwright-cli localstorage-list
|
||||
playwright-cli localstorage-get theme
|
||||
playwright-cli localstorage-set theme dark
|
||||
playwright-cli localstorage-delete theme
|
||||
playwright-cli localstorage-clear
|
||||
|
||||
# SessionStorage
|
||||
playwright-cli sessionstorage-list
|
||||
playwright-cli sessionstorage-get step
|
||||
playwright-cli sessionstorage-set step 3
|
||||
playwright-cli sessionstorage-delete step
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
```bash
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||
playwright-cli route-list
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
### DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli console
|
||||
playwright-cli console warning
|
||||
playwright-cli requests
|
||||
playwright-cli request 5
|
||||
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||
playwright-cli run-code --filename=script.js
|
||||
playwright-cli tracing-start
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli video-start video.webm
|
||||
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
|
||||
playwright-cli video-stop
|
||||
|
||||
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
|
||||
playwright-cli video-show-actions --duration=600 --position=top-right
|
||||
playwright-cli video-hide-actions
|
||||
|
||||
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
|
||||
playwright-cli show --annotate
|
||||
|
||||
# generate a Playwright locator for an element from its ref or selector
|
||||
playwright-cli generate-locator e5 --raw
|
||||
|
||||
# show a persistent highlight overlay for an element, optionally with a custom style
|
||||
playwright-cli highlight e5
|
||||
playwright-cli highlight e5 --style="outline: 3px dashed red"
|
||||
# hide a single element highlight, or all page highlights when no target is given
|
||||
playwright-cli highlight e5 --hide
|
||||
playwright-cli highlight --hide
|
||||
```
|
||||
|
||||
## Raw output
|
||||
|
||||
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
|
||||
|
||||
```bash
|
||||
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
|
||||
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
|
||||
playwright-cli --raw snapshot > before.yml
|
||||
playwright-cli click e5
|
||||
playwright-cli --raw snapshot > after.yml
|
||||
diff before.yml after.yml
|
||||
TOKEN=$(playwright-cli --raw cookie-get session_id)
|
||||
playwright-cli --raw localstorage-get theme
|
||||
```
|
||||
|
||||
For structured output wrapping every reply as JSON, pass --json
|
||||
```bash
|
||||
playwright-cli list --json
|
||||
```
|
||||
|
||||
## Open parameters
|
||||
```bash
|
||||
# Use specific browser when creating session
|
||||
playwright-cli open --browser=chrome
|
||||
playwright-cli open --browser=firefox
|
||||
playwright-cli open --browser=webkit
|
||||
playwright-cli open --browser=msedge
|
||||
|
||||
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
|
||||
# Prefer this when a mobile layout is acceptable: mobile pages are usually
|
||||
# lighter, so snapshots are smaller and cheaper.
|
||||
playwright-cli open --mobile
|
||||
playwright-cli open --device="iPhone 15"
|
||||
|
||||
# Use persistent profile (by default profile is in-memory)
|
||||
playwright-cli open --persistent
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open --profile=/path/to/profile
|
||||
|
||||
# Connect to browser via Playwright Extension
|
||||
playwright-cli attach --extension=chrome
|
||||
|
||||
# Connect to a running Chrome or Edge by channel name
|
||||
playwright-cli attach --cdp=chrome
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Connect to a running browser via CDP endpoint
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
|
||||
# Start with config file
|
||||
playwright-cli open --config=my-config.json
|
||||
|
||||
# Close the browser
|
||||
playwright-cli close
|
||||
# Detach from an attached browser (leaves the external browser running)
|
||||
playwright-cli -s=msedge detach
|
||||
# Delete user data for the default session
|
||||
playwright-cli delete-data
|
||||
```
|
||||
|
||||
## URLs with `&` on Windows
|
||||
|
||||
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
|
||||
|
||||
```batch
|
||||
playwright-cli goto "https://example.com/?a=1^&b=2"
|
||||
```
|
||||
|
||||
```powershell
|
||||
playwright-cli --% goto "https://example.com/?a=1&b=2"
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
After each command, playwright-cli provides a snapshot of the current browser state.
|
||||
|
||||
```bash
|
||||
> playwright-cli goto https://example.com
|
||||
### Page
|
||||
- Page URL: https://example.com/
|
||||
- Page Title: Example Domain
|
||||
### Snapshot
|
||||
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
|
||||
```
|
||||
|
||||
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
|
||||
|
||||
```bash
|
||||
# default - save to a file with timestamp-based name
|
||||
playwright-cli snapshot
|
||||
|
||||
# save to file, use when snapshot is a part of the workflow result
|
||||
playwright-cli snapshot --filename=after-click.yaml
|
||||
|
||||
# snapshot an element instead of the whole page
|
||||
playwright-cli snapshot "#main"
|
||||
|
||||
# limit snapshot depth for efficiency, take a partial snapshot afterwards
|
||||
playwright-cli snapshot --depth=4
|
||||
playwright-cli snapshot e34
|
||||
|
||||
# include each element's bounding box as [box=x,y,width,height]
|
||||
playwright-cli snapshot --boxes
|
||||
|
||||
# search a large snapshot instead of capturing it all — returns matching nodes
|
||||
# with 3 lines of context around each match (like grep -C)
|
||||
playwright-cli find "Add to cart"
|
||||
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
|
||||
```
|
||||
|
||||
## Targeting elements
|
||||
|
||||
By default, use refs from the snapshot to interact with page elements.
|
||||
|
||||
```bash
|
||||
# get snapshot with refs
|
||||
playwright-cli snapshot
|
||||
|
||||
# interact using a ref
|
||||
playwright-cli click e15
|
||||
```
|
||||
|
||||
You can also use css selectors or Playwright locators.
|
||||
|
||||
```bash
|
||||
# css selector
|
||||
playwright-cli click "#main > button.submit"
|
||||
|
||||
# role locator
|
||||
playwright-cli click "getByRole('button', { name: 'Submit' })"
|
||||
|
||||
# test id
|
||||
playwright-cli click "getByTestId('submit-button')"
|
||||
```
|
||||
|
||||
## Browser Sessions
|
||||
|
||||
```bash
|
||||
# create new browser session named "mysession" with persistent profile
|
||||
playwright-cli -s=mysession open example.com --persistent
|
||||
# same with manually specified profile directory (use when requested explicitly)
|
||||
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||
playwright-cli -s=mysession click e6
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||
|
||||
playwright-cli list
|
||||
# Close all browsers
|
||||
playwright-cli close-all
|
||||
# Forcefully kill all browser processes
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
|
||||
|
||||
```bash
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com/form
|
||||
playwright-cli snapshot
|
||||
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Multi-tab workflow
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tab-new https://example.com/other
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-select 0
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Debugging with DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli console
|
||||
playwright-cli requests
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tracing-start
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Interactive session
|
||||
|
||||
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli show --annotate
|
||||
```
|
||||
|
||||
## Specific tasks
|
||||
|
||||
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
|
||||
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
|
||||
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Inspecting Element Attributes
|
||||
|
||||
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot
|
||||
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||
|
||||
# get the element's id
|
||||
playwright-cli eval "el => el.id" e7
|
||||
|
||||
# get all CSS classes
|
||||
playwright-cli eval "el => el.className" e7
|
||||
|
||||
# get a specific attribute
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e7
|
||||
playwright-cli eval "el => el.getAttribute('aria-label')" e7
|
||||
|
||||
# get a computed style property
|
||||
playwright-cli eval "el => getComputedStyle(el).display" e7
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Running Playwright Tests
|
||||
|
||||
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
|
||||
# Run all tests through a custom npm script
|
||||
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
|
||||
```
|
||||
|
||||
# Debugging Playwright Tests
|
||||
|
||||
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||
|
||||
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||
|
||||
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
|
||||
|
||||
```bash
|
||||
# Run the test
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
|
||||
# ...
|
||||
# ... debugging instructions for "tw-abcdef" session ...
|
||||
# ...
|
||||
|
||||
# Attach to the test
|
||||
playwright-cli attach tw-abcdef
|
||||
```
|
||||
|
||||
Keep the test running in the background while you explore and look for a fix.
|
||||
The test is paused at the start, so you should step over or pause at a particular location
|
||||
where the problem is most likely to be.
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||
|
||||
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Request Mocking
|
||||
|
||||
Intercept, mock, modify, and block network requests.
|
||||
|
||||
## CLI Route Commands
|
||||
|
||||
```bash
|
||||
# Mock with custom status
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
|
||||
# Mock with JSON body
|
||||
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||
|
||||
# Mock with custom headers
|
||||
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||
|
||||
# Remove headers from requests
|
||||
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||
|
||||
# List active routes
|
||||
playwright-cli route-list
|
||||
|
||||
# Remove a route or all routes
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```
|
||||
**/api/users - Exact path match
|
||||
**/api/*/details - Wildcard in path
|
||||
**/*.{png,jpg,jpeg} - Match file extensions
|
||||
**/search?q=* - Match query parameters
|
||||
```
|
||||
|
||||
## Advanced Mocking with run-code
|
||||
|
||||
For conditional responses, request body inspection, response modification, or delays:
|
||||
|
||||
### Conditional Response Based on Request
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/login', route => {
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.username === 'admin') {
|
||||
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||
} else {
|
||||
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||
}
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Modify Real Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.isPremium = true;
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Simulate Network Failures
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||
}"
|
||||
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||
```
|
||||
|
||||
### Delayed Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/slow', async route => {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||
});
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,241 @@
|
||||
# Running Custom Playwright Code
|
||||
|
||||
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||
|
||||
## Syntax
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
// Your Playwright code here
|
||||
// Access page.context() for browser context operations
|
||||
}"
|
||||
```
|
||||
|
||||
You can also load the function from a file:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code --filename=./my-script.js
|
||||
```
|
||||
|
||||
|
||||
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||
import/export/require syntax is not supported.
|
||||
|
||||
## Geolocation
|
||||
|
||||
```bash
|
||||
# Grant geolocation permission and set location
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
}"
|
||||
|
||||
# Set location to London
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||
}"
|
||||
|
||||
# Clear geolocation override
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().clearPermissions();
|
||||
}"
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
```bash
|
||||
# Grant multiple permissions
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions([
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'camera',
|
||||
'microphone'
|
||||
]);
|
||||
}"
|
||||
|
||||
# Grant permissions for specific origin
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read'], {
|
||||
origin: 'https://example.com'
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Media Emulation
|
||||
|
||||
```bash
|
||||
# Emulate dark color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
}"
|
||||
|
||||
# Emulate light color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
}"
|
||||
|
||||
# Emulate reduced motion
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}"
|
||||
|
||||
# Emulate print media
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
}"
|
||||
```
|
||||
|
||||
## Wait Strategies
|
||||
|
||||
```bash
|
||||
# Wait for network idle
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}"
|
||||
|
||||
# Wait for specific element
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||
}"
|
||||
|
||||
# Wait for function to return true
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForFunction(() => window.appReady === true);
|
||||
}"
|
||||
|
||||
# Wait with timeout
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||
}"
|
||||
```
|
||||
|
||||
## Frames and Iframes
|
||||
|
||||
```bash
|
||||
# Work with iframe
|
||||
playwright-cli run-code "async page => {
|
||||
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||
await frame.locator('button').click();
|
||||
}"
|
||||
|
||||
# Get all frames
|
||||
playwright-cli run-code "async page => {
|
||||
const frames = page.frames();
|
||||
return frames.map(f => f.url());
|
||||
}"
|
||||
```
|
||||
|
||||
## File Downloads
|
||||
|
||||
```bash
|
||||
# Handle file download
|
||||
playwright-cli run-code "async page => {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download' }).click();
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs('./downloaded-file.pdf');
|
||||
return download.suggestedFilename();
|
||||
}"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
```bash
|
||||
# Read clipboard (requires permission)
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read']);
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
}"
|
||||
|
||||
# Write to clipboard
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||
}"
|
||||
```
|
||||
|
||||
## Page Information
|
||||
|
||||
```bash
|
||||
# Get page title
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.title();
|
||||
}"
|
||||
|
||||
# Get current URL
|
||||
playwright-cli run-code "async page => {
|
||||
return page.url();
|
||||
}"
|
||||
|
||||
# Get page content
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.content();
|
||||
}"
|
||||
|
||||
# Get viewport size
|
||||
playwright-cli run-code "async page => {
|
||||
return page.viewportSize();
|
||||
}"
|
||||
```
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
```bash
|
||||
# Execute JavaScript and return result
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(() => {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
cookiesEnabled: navigator.cookieEnabled
|
||||
};
|
||||
});
|
||||
}"
|
||||
|
||||
# Pass arguments to evaluate
|
||||
playwright-cli run-code "async page => {
|
||||
const multiplier = 5;
|
||||
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||
}"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```bash
|
||||
# Try-catch in run-code
|
||||
playwright-cli run-code "async page => {
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||
return 'clicked';
|
||||
} catch (e) {
|
||||
return 'element not found';
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
```bash
|
||||
# Login and save state
|
||||
playwright-cli run-code "async page => {
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
return 'Login successful';
|
||||
}"
|
||||
|
||||
# Scrape data from multiple pages
|
||||
playwright-cli run-code "async page => {
|
||||
const results = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.goto(\`https://example.com/page/\${i}\`);
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
results.push(...items);
|
||||
}
|
||||
return results;
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,225 @@
|
||||
# Browser Session Management
|
||||
|
||||
Run multiple isolated browser sessions concurrently with state persistence.
|
||||
|
||||
## Named Browser Sessions
|
||||
|
||||
Use `-s` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Browser 1: Authentication flow
|
||||
playwright-cli -s=auth open https://app.example.com/login
|
||||
|
||||
# Browser 2: Public browsing (separate cookies, storage)
|
||||
playwright-cli -s=public open https://example.com
|
||||
|
||||
# Commands are isolated by browser session
|
||||
playwright-cli -s=auth fill e1 "user@example.com"
|
||||
playwright-cli -s=public snapshot
|
||||
```
|
||||
|
||||
## Browser Session Isolation Properties
|
||||
|
||||
Each browser session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Browser Session Commands
|
||||
|
||||
```bash
|
||||
# List all browser sessions
|
||||
playwright-cli list
|
||||
|
||||
# Stop a browser session (close the browser)
|
||||
playwright-cli close # stop the default browser
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
|
||||
# Stop all browser sessions
|
||||
playwright-cli close-all
|
||||
|
||||
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||
playwright-cli kill-all
|
||||
|
||||
# Delete browser session user data (profile directory)
|
||||
playwright-cli delete-data # delete default browser data
|
||||
playwright-cli -s=mysession delete-data # delete named browser data
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
|
||||
Set a default browser session name via environment variable:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||
playwright-cli open example.com # Uses "mysession" automatically
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all browsers
|
||||
playwright-cli -s=site1 open https://site1.com &
|
||||
playwright-cli -s=site2 open https://site2.com &
|
||||
playwright-cli -s=site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Take snapshots from each
|
||||
playwright-cli -s=site1 snapshot
|
||||
playwright-cli -s=site2 snapshot
|
||||
playwright-cli -s=site3 snapshot
|
||||
|
||||
# Cleanup
|
||||
playwright-cli close-all
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
playwright-cli -s=variant-a screenshot
|
||||
playwright-cli -s=variant-b screenshot
|
||||
```
|
||||
|
||||
### Persistent Profile
|
||||
|
||||
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||
|
||||
```bash
|
||||
# Use persistent profile (auto-generated location)
|
||||
playwright-cli open https://example.com --persistent
|
||||
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open https://example.com --profile=/path/to/profile
|
||||
```
|
||||
|
||||
## Attaching to a Running Browser
|
||||
|
||||
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||
|
||||
### Attach by channel name
|
||||
|
||||
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||
|
||||
```bash
|
||||
# Attach to Chrome
|
||||
playwright-cli attach --cdp=chrome
|
||||
|
||||
# Attach to Chrome Canary
|
||||
playwright-cli attach --cdp=chrome-canary
|
||||
|
||||
# Attach to Microsoft Edge
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Attach to Edge Dev
|
||||
playwright-cli attach --cdp=msedge-dev
|
||||
```
|
||||
|
||||
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||
|
||||
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||
|
||||
### Attach via CDP endpoint
|
||||
|
||||
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
```
|
||||
|
||||
### Attach via browser extension
|
||||
|
||||
Connect to a browser with the Playwright extension installed:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --extension
|
||||
```
|
||||
|
||||
### Detach
|
||||
|
||||
Tear down an attached session without affecting the external browser:
|
||||
|
||||
```bash
|
||||
# Detach the default attached session
|
||||
playwright-cli detach
|
||||
|
||||
# Detach a specific attached session
|
||||
playwright-cli -s=msedge detach
|
||||
```
|
||||
|
||||
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||
|
||||
## Default Browser Session
|
||||
|
||||
When `-s` is omitted, commands use the default browser session:
|
||||
|
||||
```bash
|
||||
# These use the same default browser session
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli close # Stops default browser
|
||||
```
|
||||
|
||||
## Browser Session Configuration
|
||||
|
||||
Configure a browser session with specific settings when opening:
|
||||
|
||||
```bash
|
||||
# Open with config file
|
||||
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||
|
||||
# Open with specific browser
|
||||
playwright-cli open https://example.com --browser=firefox
|
||||
|
||||
# Open in headed mode
|
||||
playwright-cli open https://example.com --headed
|
||||
|
||||
# Open with persistent profile
|
||||
playwright-cli open https://example.com --persistent
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Browser Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
playwright-cli -s=github-auth open https://github.com
|
||||
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
playwright-cli -s=s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Stop browsers when done
|
||||
playwright-cli -s=auth close
|
||||
playwright-cli -s=scrape close
|
||||
|
||||
# Or stop all at once
|
||||
playwright-cli close-all
|
||||
|
||||
# If browsers become unresponsive or zombie processes remain
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
### 3. Delete Stale Browser Data
|
||||
|
||||
```bash
|
||||
# Remove old browser data to free disk space
|
||||
playwright-cli -s=oldsession delete-data
|
||||
```
|
||||
@@ -0,0 +1,275 @@
|
||||
# Storage Management
|
||||
|
||||
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||
|
||||
## Storage State
|
||||
|
||||
Save and restore complete browser state including cookies and storage.
|
||||
|
||||
### Save Storage State
|
||||
|
||||
```bash
|
||||
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||
playwright-cli state-save
|
||||
|
||||
# Save to specific filename
|
||||
playwright-cli state-save my-auth-state.json
|
||||
```
|
||||
|
||||
### Restore Storage State
|
||||
|
||||
```bash
|
||||
# Load storage state from file
|
||||
playwright-cli state-load my-auth-state.json
|
||||
|
||||
# Reload page to apply cookies
|
||||
playwright-cli open https://example.com
|
||||
```
|
||||
|
||||
### Storage State File Format
|
||||
|
||||
The saved file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"value": "abc123",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"expires": 1893456000,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.com",
|
||||
"localStorage": [
|
||||
{ "name": "theme", "value": "dark" },
|
||||
{ "name": "user_id", "value": "12345" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
### List All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list
|
||||
```
|
||||
|
||||
### Filter Cookies by Domain
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
```
|
||||
|
||||
### Filter Cookies by Path
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --path=/api
|
||||
```
|
||||
|
||||
### Get Specific Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-get session_id
|
||||
```
|
||||
|
||||
### Set a Cookie
|
||||
|
||||
```bash
|
||||
# Basic cookie
|
||||
playwright-cli cookie-set session abc123
|
||||
|
||||
# Cookie with options
|
||||
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||
|
||||
# Cookie with expiration (Unix timestamp)
|
||||
playwright-cli cookie-set remember_me token123 --expires=1893456000
|
||||
```
|
||||
|
||||
### Delete a Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-delete session_id
|
||||
```
|
||||
|
||||
### Clear All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Cookies or Custom Options
|
||||
|
||||
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().addCookies([
|
||||
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||
]);
|
||||
}"
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
### List All localStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-get token
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set theme dark
|
||||
```
|
||||
|
||||
### Set JSON Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-delete token
|
||||
```
|
||||
|
||||
### Clear All localStorage
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Operations
|
||||
|
||||
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('token', 'jwt_abc123');
|
||||
localStorage.setItem('user_id', '12345');
|
||||
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Session Storage
|
||||
|
||||
### List All sessionStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-get form_data
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-set step 3
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-delete step
|
||||
```
|
||||
|
||||
### Clear sessionStorage
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
## IndexedDB
|
||||
|
||||
### List Databases
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(async () => {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases;
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Delete Database
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
indexedDB.deleteDatabase('myDatabase');
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication State Reuse
|
||||
|
||||
```bash
|
||||
# Step 1: Login and save state
|
||||
playwright-cli open https://app.example.com/login
|
||||
playwright-cli snapshot
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
|
||||
# Save the authenticated state
|
||||
playwright-cli state-save auth.json
|
||||
|
||||
# Step 2: Later, restore state and skip login
|
||||
playwright-cli state-load auth.json
|
||||
playwright-cli open https://app.example.com/dashboard
|
||||
# Already logged in!
|
||||
```
|
||||
|
||||
### Save and Restore Roundtrip
|
||||
|
||||
```bash
|
||||
# Set up authentication state
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||
|
||||
# Save state to file
|
||||
playwright-cli state-save my-session.json
|
||||
|
||||
# ... later, in a new session ...
|
||||
|
||||
# Restore state
|
||||
playwright-cli state-load my-session.json
|
||||
playwright-cli open https://example.com
|
||||
# Cookies and localStorage are restored!
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit storage state files containing auth tokens
|
||||
- Add `*.auth-state.json` to `.gitignore`
|
||||
- Delete state files after automation completes
|
||||
- Use environment variables for sensitive data
|
||||
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||
@@ -0,0 +1,433 @@
|
||||
# Test generation (plan → generate → heal)
|
||||
|
||||
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
|
||||
|
||||
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
|
||||
- **Plan** — explore the app, produce a spec file describing what to test.
|
||||
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||
|
||||
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
|
||||
|
||||
---
|
||||
|
||||
## 0. How generation works
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
|
||||
|
||||
```bash
|
||||
# Start a session
|
||||
playwright-cli open https://example.com/login
|
||||
|
||||
# Take a snapshot to see elements
|
||||
playwright-cli snapshot
|
||||
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||
|
||||
# Fill form fields - generates code automatically
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
|
||||
playwright-cli fill e2 "password123"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
|
||||
playwright-cli click e3
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
```
|
||||
|
||||
### Building a test file
|
||||
|
||||
Collect the generated code into a Playwright test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('login flow', async ({ page }) => {
|
||||
// Generated code from playwright-cli session:
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Add assertions
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
### Use semantic locators
|
||||
|
||||
The generated code uses role-based locators when possible, which are more resilient:
|
||||
|
||||
```typescript
|
||||
// Generated (good - semantic)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Avoid (fragile - CSS selectors)
|
||||
await page.locator('#submit-btn').click();
|
||||
```
|
||||
|
||||
### Explore before recording
|
||||
|
||||
Take snapshots to understand the page structure before recording actions:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
# Review the element structure
|
||||
playwright-cli click e5
|
||||
```
|
||||
|
||||
### Add assertions manually
|
||||
|
||||
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||
|
||||
- `toBeVisible()` — element is rendered and visible
|
||||
- `toHaveText(text)` — element text content matches
|
||||
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||
|
||||
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||
|
||||
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||
|
||||
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||
|
||||
```bash
|
||||
# Get a stable locator for an element ref to use in the assertion
|
||||
playwright-cli --raw generate-locator e5
|
||||
# getByRole('button', { name: 'Submit' })
|
||||
|
||||
# Capture expected text content for toHaveText
|
||||
playwright-cli --raw eval "el => el.textContent" e5
|
||||
|
||||
# Capture expected input value for toHaveValue/toBeEmpty
|
||||
playwright-cli --raw eval "el => el.value" e5
|
||||
|
||||
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||
# (whole page, or use a ref to scope to a region)
|
||||
playwright-cli --raw snapshot
|
||||
playwright-cli --raw snapshot e5
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Generated action
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Manual assertions using the outputs above:
|
||||
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
|
||||
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||
|
||||
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||
await expect(page).toMatchAriaSnapshot(`
|
||||
- heading "Welcome, user"
|
||||
- link /\\d+ new messages?/
|
||||
- button "Sign out"
|
||||
`);
|
||||
|
||||
// toMatchAriaSnapshot scoped to a region
|
||||
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||
- link "Home"
|
||||
- link /\\d+ new messages?/
|
||||
- link "Profile"
|
||||
`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Planning
|
||||
|
||||
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||
|
||||
### 1.1 Prerequisite: workspace
|
||||
|
||||
Check the workspace has Playwright installed before anything else:
|
||||
|
||||
```bash
|
||||
# Either of these confirms a workspace:
|
||||
test -f playwright.config.ts || test -f playwright.config.js
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
If there is no Playwright install, bootstrap one and let the user pick the defaults:
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
### 1.2 Prerequisite: seed test
|
||||
|
||||
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||
|
||||
Minimum viable seed:
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
await page.goto('https://example.com/');
|
||||
});
|
||||
```
|
||||
|
||||
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||
|
||||
```ts
|
||||
// tests/fixtures.ts
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.goto('https://example.com/');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
// Fixture already navigates. This empty body tells agents where to start.
|
||||
});
|
||||
```
|
||||
|
||||
If no seed exists, create one that at least navigates to the app.
|
||||
|
||||
### 1.3 Explore the app
|
||||
|
||||
Launch the app via the seed in the background and attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
|
||||
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
Resume so the seed runs, then probe the app:
|
||||
|
||||
```bash
|
||||
playwright-cli resume # resume so that seed test runs fully
|
||||
playwright-cli snapshot # inventory of interactive elements
|
||||
playwright-cli click e5 # follow a flow
|
||||
playwright-cli eval "location.href" # read URL / state
|
||||
playwright-cli show --annotate # ask the user to point at something
|
||||
```
|
||||
|
||||
Map out:
|
||||
|
||||
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||
- Primary user journeys end-to-end.
|
||||
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||
- Persistence: reload, local/session storage, URL fragments.
|
||||
- Navigation: which controls change the URL, back/forward behaviour.
|
||||
|
||||
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
**Important**: Stop the background test when done exploring.
|
||||
|
||||
### 1.4 Write the spec file
|
||||
|
||||
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||
|
||||
```markdown
|
||||
# <Feature> Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
<One paragraph describing what the feature does and why it matters.>
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. <Group Name>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1. <kebab-case-scenario-name>
|
||||
|
||||
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. <Concrete user step>
|
||||
- expect: <observable outcome>
|
||||
- expect: <another observable outcome>
|
||||
2. <Next step>
|
||||
- expect: <outcome>
|
||||
|
||||
#### 1.2. <next-scenario>
|
||||
...
|
||||
|
||||
### 2. <Next Group>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
...
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate
|
||||
|
||||
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||
|
||||
### 2.1 Inputs
|
||||
|
||||
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||
|
||||
### 2.2 Generate one scenario
|
||||
|
||||
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
|
||||
playwright-cli attach tw-XXXX
|
||||
# resume
|
||||
```
|
||||
|
||||
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
|
||||
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||
|
||||
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # find refs
|
||||
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||
playwright-cli press Enter
|
||||
playwright-cli click e7
|
||||
```
|
||||
|
||||
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
|
||||
|
||||
Collect the generated code and write the test file at the path given in the spec:
|
||||
|
||||
```ts
|
||||
// spec: specs/basic-operations.plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||
|
||||
test.describe('Signing in and out', () => {
|
||||
test('should sign in', async ({ page }) => {
|
||||
// 1. Navigate to the application
|
||||
// (handled by the seed fixture)
|
||||
|
||||
// 2. Type 'John Doe' into the username field
|
||||
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||
|
||||
// 3. Type password
|
||||
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||
|
||||
// 4. Press Enter to submit
|
||||
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||
|
||||
### 2.3 Generate multiple scenarios
|
||||
|
||||
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||
|
||||
### 2.4 Run generated tests
|
||||
|
||||
After generation, run the new tests once:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
|
||||
```
|
||||
|
||||
Any failure goes to Section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heal
|
||||
|
||||
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||
|
||||
### 3.1 Find failing tests
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
```
|
||||
|
||||
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||
|
||||
### 3.2 Debug one failure
|
||||
|
||||
Run the single failing test in debug mode in the background, then attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # did the element change / move / rename?
|
||||
playwright-cli console # app-side errors?
|
||||
playwright-cli requests # failed request? wrong payload?
|
||||
playwright-cli show --annotate # ask the user to point somewhere
|
||||
```
|
||||
|
||||
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||
|
||||
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
|
||||
|
||||
### 3.3 Apply the fix
|
||||
|
||||
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||
|
||||
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||
|
||||
### 3.4 Reconcile with the spec
|
||||
|
||||
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||
|
||||
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||
- the scenario id (e.g. `2.3`),
|
||||
- the spec lines that no longer match,
|
||||
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||
|
||||
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||
|
||||
### 3.5 Iteration and giving up
|
||||
|
||||
- Fix failures one at a time; rerun after each.
|
||||
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
| For... | See |
|
||||
|---|---|
|
||||
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||
@@ -0,0 +1,139 @@
|
||||
# Tracing
|
||||
|
||||
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Start trace recording
|
||||
playwright-cli tracing-start
|
||||
|
||||
# Perform actions
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e1
|
||||
playwright-cli fill e2 "test"
|
||||
|
||||
# Stop trace recording
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
## Trace Output Files
|
||||
|
||||
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||
|
||||
### `trace-{timestamp}.trace`
|
||||
|
||||
**Action log** - The main trace file containing:
|
||||
- Every action performed (clicks, fills, navigations)
|
||||
- DOM snapshots before and after each action
|
||||
- Screenshots at each step
|
||||
- Timing information
|
||||
- Console messages
|
||||
- Source locations
|
||||
|
||||
### `trace-{timestamp}.network`
|
||||
|
||||
**Network log** - Complete network activity:
|
||||
- All HTTP requests and responses
|
||||
- Request headers and bodies
|
||||
- Response headers and bodies
|
||||
- Timing (DNS, connect, TLS, TTFB, download)
|
||||
- Resource sizes
|
||||
- Failed requests and errors
|
||||
|
||||
### `resources/`
|
||||
|
||||
**Resources directory** - Cached resources:
|
||||
- Images, fonts, stylesheets, scripts
|
||||
- Response bodies for replay
|
||||
- Assets needed to reconstruct page state
|
||||
|
||||
## What Traces Capture
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||
| **DOM** | Full DOM snapshot before/after each action |
|
||||
| **Screenshots** | Visual state at each step |
|
||||
| **Network** | All requests, responses, headers, bodies, timing |
|
||||
| **Console** | All console.log, warn, error messages |
|
||||
| **Timing** | Precise timing for each operation |
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Actions
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://app.example.com
|
||||
|
||||
# This click fails - why?
|
||||
playwright-cli click e5
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Open trace to see DOM state when click was attempted
|
||||
```
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://slow-site.com
|
||||
playwright-cli tracing-stop
|
||||
|
||||
# View network waterfall to identify slow resources
|
||||
```
|
||||
|
||||
### Capturing Evidence
|
||||
|
||||
```bash
|
||||
# Record a complete user flow for documentation
|
||||
playwright-cli tracing-start
|
||||
|
||||
playwright-cli open https://app.example.com/checkout
|
||||
playwright-cli fill e1 "4111111111111111"
|
||||
playwright-cli fill e2 "12/25"
|
||||
playwright-cli fill e3 "123"
|
||||
playwright-cli click e4
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Trace shows exact sequence of events
|
||||
```
|
||||
|
||||
## Trace vs Video vs Screenshot
|
||||
|
||||
| Feature | Trace | Video | Screenshot |
|
||||
|---------|-------|-------|------------|
|
||||
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||
| **DOM inspection** | Yes | No | No |
|
||||
| **Network details** | Yes | No | No |
|
||||
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||
| **File size** | Medium | Large | Small |
|
||||
| **Best for** | Debugging | Demos | Quick capture |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Tracing Before the Problem
|
||||
|
||||
```bash
|
||||
# Trace the entire flow, not just the failing step
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://example.com
|
||||
# ... all steps leading to the issue ...
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
### 2. Clean Up Old Traces
|
||||
|
||||
Traces can consume significant disk space:
|
||||
|
||||
```bash
|
||||
# Remove traces older than 7 days
|
||||
find .playwright-cli/traces -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Traces add overhead to automation
|
||||
- Large traces can consume significant disk space
|
||||
- Some dynamic content may not replay perfectly
|
||||
@@ -0,0 +1,143 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Open browser first
|
||||
playwright-cli open
|
||||
|
||||
# Start recording
|
||||
playwright-cli video-start demo.webm
|
||||
|
||||
# Add a chapter marker for section transitions
|
||||
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||
|
||||
# Navigate and perform actions
|
||||
playwright-cli goto https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli click e1
|
||||
|
||||
# Add another chapter
|
||||
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||
playwright-cli fill e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
playwright-cli video-stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
playwright-cli video-start recordings/login-flow-2024-01-15.webm
|
||||
playwright-cli video-start recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 2. Record entire hero scripts.
|
||||
|
||||
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||
|
||||
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||
3) Use playwright-cli run-code --filename your-script.js
|
||||
|
||||
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||
|
||||
```js
|
||||
async page => {
|
||||
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
|
||||
// Show a chapter card — blurs the page and shows a dialog.
|
||||
// Blocks until duration expires, then auto-removes.
|
||||
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||
// overlay via await page.screencast.showOverlay().
|
||||
await page.screencast.showChapter('Adding Todo Items', {
|
||||
description: 'We will add several items to the todo list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Perform action
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Show next chapter
|
||||
await page.screencast.showChapter('Verifying Results', {
|
||||
description: 'Checking the item appeared in the list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Add a sticky annotation that stays while you perform actions.
|
||||
// Overlays are pointer-events: none, so they won't block clicks.
|
||||
const annotation = await page.screencast.showOverlay(`
|
||||
<div style="position: absolute; top: 8px; right: 8px;
|
||||
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 8px; font-size: 13px; color: white;">
|
||||
✓ Item added successfully
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Perform more actions while the annotation is visible
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Remove the annotation when done
|
||||
await annotation.dispose();
|
||||
|
||||
// You can also highlight relevant locators and provide contextual annotations.
|
||||
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||
await page.screencast.showOverlay(`
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y}px;
|
||||
left: ${bounds.x}px;
|
||||
width: ${bounds.width}px;
|
||||
height: ${bounds.height}px;
|
||||
border: 1px solid red;">
|
||||
</div>
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y + bounds.height + 5}px;
|
||||
left: ${bounds.x + bounds.width / 2}px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px;
|
||||
background: #808080;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: white;">Check it out, it is right above this text
|
||||
</div>
|
||||
`, { duration: 2000 });
|
||||
|
||||
await page.screencast.stop();
|
||||
}
|
||||
```
|
||||
|
||||
Embrace creativity, overlays are powerful.
|
||||
|
||||
### Overlay API Summary
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||
|
||||
## Tracing vs Video
|
||||
|
||||
| Feature | Video | Tracing |
|
||||
|---------|-------|---------|
|
||||
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||
| Use case | Demos, documentation | Debugging, analysis |
|
||||
| Size | Larger | Smaller |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
Vendored
+1
-10
@@ -7,16 +7,7 @@
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"env": {
|
||||
"FLASK_APP": "backend/main.py",
|
||||
"FLASK_DEBUG": "1",
|
||||
"SECRET_KEY": "dev-secret-key-change-in-production",
|
||||
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
|
||||
"DIGEST_TOKEN_SECRET": "dev-digest-token",
|
||||
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
|
||||
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
|
||||
"FRONTEND_URL": "https://macbook:5173"
|
||||
},
|
||||
"envFile": "${workspaceFolder}/backend/.env",
|
||||
"args": [
|
||||
"run",
|
||||
"--host=0.0.0.0",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md
|
||||
|
||||
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates over SSE.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (run from `backend/`)
|
||||
- Activate venv: `source .venv/bin/activate`
|
||||
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `main.py`)
|
||||
- Required env vars: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing
|
||||
- Optional persistence switch: `USE_MONGODB` (`true` | `false`). Defaults to `true`; set `MONGO_URI` (and optionally `MONGO_DB_NAME`). Set to `false` to use TinyDB instead.
|
||||
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`). For MongoDB these also select the default database name (`chore_db`, `chore_db_test`, `chore_db_e2e`) unless `MONGO_DB_NAME` is set.
|
||||
- Tests: `pytest tests/` — `conftest.py` forces `DB_ENV=test`, `USE_MONGODB=true`, `MONGO_URI=mongomock`, and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`
|
||||
- Python imports assume `backend/` is on `sys.path` (set by `conftest.py` / `flask run` cwd). Run pytest from `backend/`.
|
||||
- Create admin user: `python scripts/create_admin.py` (admin role cannot be set via signup)
|
||||
|
||||
### Frontend (run from `frontend/`)
|
||||
- Dev: `npm run dev` (Vite, https://localhost:5173)
|
||||
- Lint: `npm run lint`
|
||||
- Type-check: `npm run type-check`
|
||||
- Unit tests: `npm run test:unit` (Vitest). Single: `npx vitest run path/to/file.spec.ts`
|
||||
- E2E: `npx playwright test` — config auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e USE_MONGODB=true MONGO_URI=mongomock`. Tests live in `e2e/`. `frontend/.env.test` contains the example MongoDB config.
|
||||
- E2E buckets are Playwright projects (see `playwright.config.ts`) targeting directories under `e2e/mode_parent/`
|
||||
|
||||
## Architecture
|
||||
|
||||
### API routing — the `/api` prefix
|
||||
- Frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`.** Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||
- `auth_api` is the only blueprint registered with a prefix: `url_prefix='/auth'` in `main.py:67`.
|
||||
- API errors return `{ error, code }` (codes in `backend/api/error_codes.py`). Frontend extracts them via `parseErrorResponse(res)` in `src/common/api.ts`.
|
||||
|
||||
### Models — strict 1:1 parity
|
||||
- Python `@dataclass`es in `backend/models/`. TypeScript interfaces in `frontend/src/common/models.ts`. Any model change requires updating both.
|
||||
- Persistence is MongoDB by default (`USE_MONGODB=true`), or TinyDB when `USE_MONGODB=false`. Both are accessed through the `LockedTable` / `MongoLockedTable` wrappers in `backend/db/db.py`. Operate on model instances with `from_dict()` / `to_dict()` — never raw dicts.
|
||||
- MongoDB client initialization is lazy (`backend/db/mongo_client.py`). `backend/gunicorn.conf.py` provides the `post_fork` hook required for multi-worker Gunicorn deployments; `backend/Dockerfile` loads it with `-c gunicorn.conf.py`.
|
||||
- Migration script: `cd backend && python -m scripts/migrate_to_mongodb [--dry-run]`. It reads TinyDB JSON files and writes them to MongoDB idempotently, backing up the originals to `<db_dir>/backups/<timestamp>/`.
|
||||
|
||||
### SSE event bus — mandatory for every mutation
|
||||
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` from `api/utils.py`. Event types in `backend/events/types/` are mirrored in `frontend/src/common/backendEvents.ts`.
|
||||
- Frontend: register listeners in `onMounted`, clean up in `onUnmounted`. SSE endpoint is `/events`.
|
||||
|
||||
### Background schedulers (started in `main.py` at boot)
|
||||
- `start_deletion_scheduler` — runs hourly, deletes accounts marked for deletion after threshold
|
||||
- `start_digest_scheduler` — email digests
|
||||
- `start_state_expiry_scheduler` — expires stale state
|
||||
- `start_chore_expiry_notification_scheduler` — chore expiry notifications
|
||||
|
||||
## Frontend conventions
|
||||
- SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only in `<script>`. All styles must be `scoped`.
|
||||
- Colors/spacing: use only `:root` CSS variables from `colors.css`. No hardcoded hex/px for themed properties.
|
||||
- Layout shells: `ParentLayout` for admin/management, `ChildLayout` for child dashboard/focus.
|
||||
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||
|
||||
## Testing gotchas
|
||||
- E2E tests use pre-authenticated sessions via `storageState` in `playwright.config.ts` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts`.
|
||||
- E2E buckets that mutate shared state (default tasks, delete-account, create-child) use isolated users. Preserve this pattern when adding new buckets.
|
||||
- Backend tests: `conftest.py` sets `DB_ENV=test` + dummy secrets. Test DB lands in `test_data/db/`, never touches production `data/`.
|
||||
|
||||
## Feature specs
|
||||
Specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is complete.
|
||||
@@ -4,7 +4,7 @@ A family-friendly application for managing chores, tasks, and rewards for childr
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Backend**: Flask (Python) with TinyDB for data persistence
|
||||
- **Backend**: Flask (Python) with TinyDB or MongoDB for data persistence
|
||||
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
|
||||
- **Deployment**: Docker with nginx reverse proxy
|
||||
|
||||
@@ -38,6 +38,37 @@ npm run dev
|
||||
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
|
||||
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
|
||||
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
|
||||
| `USE_MONGODB` | Use MongoDB (`true`/`false`) | `true` |
|
||||
| `MONGO_URI` | MongoDB connection URI (required when `USE_MONGODB=true`) | — |
|
||||
| `MONGO_DB_NAME` | MongoDB database name (optional) | Parsed from `MONGO_URI`, or `chore_db`/`chore_db_test`/`chore_db_e2e` based on `DB_ENV` |
|
||||
|
||||
### Database Backend
|
||||
|
||||
The application supports two persistence backends:
|
||||
|
||||
- **MongoDB** (default): Set `MONGO_URI` (and optionally `MONGO_DB_NAME`). This is the recommended backend for production and managed hosting (e.g., MongoDB Atlas).
|
||||
- **TinyDB**: JSON-file storage in `backend/data/db/` (or `backend/test_data/db/` for `test`/`e2e`). Opt in by setting `USE_MONGODB=false`.
|
||||
|
||||
#### Migrating from TinyDB to MongoDB
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
# Dry run to preview what will be migrated
|
||||
python -m scripts.migrate_to_mongodb --dry-run
|
||||
|
||||
# Run the migration (backs up TinyDB files first)
|
||||
python -m scripts.migrate_to_mongodb
|
||||
```
|
||||
|
||||
The migration script reads the existing TinyDB JSON files and inserts each record into the matching MongoDB collection, skipping records that already exist. Original TinyDB files are backed up to `backend/data/db/backups/<timestamp>/`.
|
||||
|
||||
#### Rolling Back to TinyDB
|
||||
|
||||
Set `USE_MONGODB=false`. The original JSON files remain in place.
|
||||
|
||||
#### Gunicorn / Docker
|
||||
|
||||
When running multiple Gunicorn workers, each worker must create its own MongoDB client after forking. This is handled automatically by `backend/gunicorn.conf.py`, which is loaded by `backend/Dockerfile` via `-c gunicorn.conf.py`.
|
||||
|
||||
### Account Deletion Scheduler
|
||||
|
||||
@@ -145,7 +176,7 @@ npm run test
|
||||
├── backend/
|
||||
│ ├── api/ # REST API endpoints
|
||||
│ ├── config/ # Configuration files
|
||||
│ ├── db/ # TinyDB setup
|
||||
│ ├── db/ # TinyDB / MongoDB persistence layer
|
||||
│ ├── events/ # SSE event system
|
||||
│ ├── models/ # Data models
|
||||
│ ├── tests/ # Backend tests
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ ENV PYTHONIOENCODING=utf-8
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Use Gunicorn instead of python main.py
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "main:app"]
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "-c", "gunicorn.conf.py", "main:app"]
|
||||
+107
-22
@@ -29,6 +29,8 @@ from db.db import (
|
||||
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
|
||||
pending_reward_db, pending_confirmations_db, tracking_events_db,
|
||||
child_overrides_db, chore_schedules_db, task_extensions_db,
|
||||
routine_db, routine_items_db, routine_schedules_db, routine_extensions_db,
|
||||
push_subscriptions_db, digest_action_tokens_db,
|
||||
)
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from api.utils import normalize_email
|
||||
@@ -43,6 +45,12 @@ try:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
||||
except ValueError:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||
try:
|
||||
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
|
||||
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
|
||||
)
|
||||
except ValueError:
|
||||
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
|
||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||
E2E_TEST_PIN = '1234'
|
||||
@@ -52,6 +60,9 @@ E2E_DELETE_PIN = '5678'
|
||||
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||
E2E_CC_PIN = '3456'
|
||||
E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
|
||||
E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
|
||||
E2E_TUTORIAL_PIN = '7890'
|
||||
|
||||
|
||||
def send_verification_email(to_email, token):
|
||||
@@ -409,18 +420,21 @@ def refresh():
|
||||
|
||||
token_record = RefreshToken.from_dict(token_dict)
|
||||
|
||||
# THEFT DETECTION: token was already used (rotated out) but replayed
|
||||
if token_record.is_used:
|
||||
logger.warning(
|
||||
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
|
||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||
)
|
||||
# Nuke ALL refresh tokens for this user
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
|
||||
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
||||
# Look up the user early (needed for both legitimate rotation and grace-period handling)
|
||||
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
if user.marked_for_deletion:
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 403
|
||||
|
||||
# Check expiry
|
||||
try:
|
||||
exp = datetime.fromisoformat(token_record.expires_at)
|
||||
@@ -437,23 +451,59 @@ def refresh():
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
# Look up the user
|
||||
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
||||
# THEFT DETECTION: token was already used (rotated out) but replayed
|
||||
if token_record.is_used:
|
||||
# Grace period: tolerate a very recent rotation to avoid false positives
|
||||
# from legitimate concurrent refresh requests (race conditions).
|
||||
grace_period = current_app.config.get(
|
||||
'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
|
||||
)
|
||||
rotated_at = token_record.rotated_at
|
||||
is_race_condition = False
|
||||
if rotated_at:
|
||||
try:
|
||||
rotated_dt = datetime.fromisoformat(rotated_at)
|
||||
if rotated_dt.tzinfo is None:
|
||||
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
|
||||
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
|
||||
is_race_condition = True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if is_race_condition:
|
||||
logger.info(
|
||||
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
|
||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||
)
|
||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||
access_token = _create_access_token(user)
|
||||
resp = jsonify({
|
||||
'email': user.email,
|
||||
'id': user.id,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified,
|
||||
})
|
||||
_set_auth_cookies(resp, access_token, raw_new_refresh)
|
||||
return resp, 200
|
||||
|
||||
logger.warning(
|
||||
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
|
||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||
)
|
||||
# Invalidate only the affected family, not every session for the user.
|
||||
refresh_tokens_db.remove(
|
||||
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
|
||||
)
|
||||
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
if user.marked_for_deletion:
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 403
|
||||
|
||||
# ROTATION: mark old token as used, create new one in same family
|
||||
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
|
||||
refresh_tokens_db.update(
|
||||
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
|
||||
TokenQuery.id == token_record.id,
|
||||
)
|
||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||
|
||||
# Issue new access token
|
||||
@@ -499,6 +549,7 @@ def e2e_create_delete_user():
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_DELETE_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
@@ -524,6 +575,33 @@ def e2e_create_cc_user():
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_CC_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-tutorial-user', methods=['POST'])
|
||||
def e2e_create_tutorial_user():
|
||||
"""Create an isolated e2e test user for tutorial tests. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_TUTORIAL_EMAIL)
|
||||
# Clean up any children from previous tutorial runs.
|
||||
existing = users_db.get(UserQuery.email == norm_email)
|
||||
if existing:
|
||||
child_db.remove(Query().user_id == existing.get('id'))
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Tutorial',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_TUTORIAL_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_TUTORIAL_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
@@ -547,6 +625,12 @@ def e2e_seed():
|
||||
chore_schedules_db.truncate()
|
||||
task_extensions_db.truncate()
|
||||
refresh_tokens_db.truncate()
|
||||
routine_db.truncate()
|
||||
routine_items_db.truncate()
|
||||
routine_schedules_db.truncate()
|
||||
routine_extensions_db.truncate()
|
||||
push_subscriptions_db.truncate()
|
||||
digest_action_tokens_db.truncate()
|
||||
|
||||
# Recreate only baseline defaults for e2e runs.
|
||||
initializeImages()
|
||||
@@ -562,6 +646,7 @@ def e2e_seed():
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_TEST_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ def get_profile():
|
||||
'image_id': user.image_id,
|
||||
'email_digest_enabled': user.email_digest_enabled,
|
||||
'push_notifications_enabled': user.push_notifications_enabled,
|
||||
'tutorial_enabled': user.tutorial_enabled,
|
||||
'tutorial_progress': user.tutorial_progress or {},
|
||||
}), 200
|
||||
|
||||
@user_api.route('/user/profile', methods=['PUT'])
|
||||
@@ -109,6 +111,37 @@ def update_profile():
|
||||
|
||||
return jsonify({'message': 'Profile updated'}), 200
|
||||
|
||||
@user_api.route('/user/tutorial-progress', methods=['PATCH'])
|
||||
def update_tutorial_progress():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json() or {}
|
||||
|
||||
if data.get('reset') is True:
|
||||
user.tutorial_progress = {}
|
||||
elif 'enabled' in data:
|
||||
user.tutorial_enabled = bool(data.get('enabled'))
|
||||
elif 'step_id' in data:
|
||||
step_id = str(data.get('step_id') or '').strip()
|
||||
if not step_id:
|
||||
return jsonify({'error': 'Missing step_id'}), 400
|
||||
progress = dict(user.tutorial_progress or {})
|
||||
progress[step_id] = bool(data.get('seen', True))
|
||||
user.tutorial_progress = progress
|
||||
else:
|
||||
return jsonify({'error': 'No-op'}), 400
|
||||
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
|
||||
return jsonify({
|
||||
'tutorial_enabled': user.tutorial_enabled,
|
||||
'tutorial_progress': user.tutorial_progress,
|
||||
}), 200
|
||||
|
||||
@user_api.route('/user/image', methods=['PUT'])
|
||||
def update_image():
|
||||
user_id = get_validated_user_id()
|
||||
|
||||
+493
-40
@@ -1,13 +1,42 @@
|
||||
# python
|
||||
import os
|
||||
from config.paths import get_database_dir
|
||||
import threading
|
||||
from config.paths import get_database_dir
|
||||
from tinydb import TinyDB
|
||||
from tinydb.queries import QueryInstance
|
||||
|
||||
from db.mongo_client import get_mongo_client, get_mongo_db_name
|
||||
|
||||
try:
|
||||
from tinydb.table import Document
|
||||
except ImportError: # pragma: no cover - tinydb version compatibility
|
||||
from tinydb.database import Document
|
||||
|
||||
|
||||
def _stable_clause_key(clause: dict) -> str:
|
||||
"""Return a stable string key for sorting MongoDB filter clauses."""
|
||||
import json
|
||||
return json.dumps(clause, sort_keys=True, default=str)
|
||||
|
||||
|
||||
try:
|
||||
from pymongo import ASCENDING
|
||||
except ImportError: # pragma: no cover - pymongo is a required dependency
|
||||
ASCENDING = 1
|
||||
|
||||
|
||||
USE_MONGODB = os.environ.get('USE_MONGODB', 'true').lower() == 'true'
|
||||
# Resolve the MongoDB database name once at module load so runtime changes to
|
||||
# DB_ENV/DATA_ENV in tests do not switch databases mid-process.
|
||||
_mongo_db_name = get_mongo_db_name() if USE_MONGODB else None
|
||||
base_dir = get_database_dir()
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TinyDB-backed table wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LockedTable:
|
||||
"""
|
||||
Thread-safe wrapper around a TinyDB table. All callable attribute access
|
||||
@@ -65,6 +94,361 @@ class LockedTable:
|
||||
with self._lock:
|
||||
return self._table.truncate()
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
return self._table.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TinyDB Query -> MongoDB filter translator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MONGO_OP_MAP = {
|
||||
'==': '$eq',
|
||||
'!=': '$ne',
|
||||
'<': '$lt',
|
||||
'<=': '$lte',
|
||||
'>': '$gt',
|
||||
'>=': '$gte',
|
||||
}
|
||||
|
||||
_NEGATED_OPS = {
|
||||
'$eq': '$ne',
|
||||
'$ne': '$eq',
|
||||
'$lt': '$gte',
|
||||
'$lte': '$gt',
|
||||
'$gt': '$lte',
|
||||
'$gte': '$lt',
|
||||
}
|
||||
|
||||
|
||||
def _field_to_mongo(field_path: tuple) -> str:
|
||||
"""Map a TinyDB field path to a MongoDB field name.
|
||||
|
||||
The model ``id`` field is stored as the MongoDB ``_id`` field, so queries
|
||||
on ``id`` are translated to queries on ``_id``.
|
||||
"""
|
||||
if len(field_path) == 1:
|
||||
return '_id' if field_path[0] == 'id' else field_path[0]
|
||||
return '.'.join('_id' if p == 'id' else p for p in field_path)
|
||||
|
||||
|
||||
def _negate_condition(cond: dict) -> dict | None:
|
||||
"""Return a MongoDB condition that negates a single-field condition."""
|
||||
if len(cond) != 1:
|
||||
return None
|
||||
field, inner = next(iter(cond.items()))
|
||||
if not isinstance(inner, dict) or len(inner) != 1:
|
||||
return None
|
||||
op, value = next(iter(inner.items()))
|
||||
if op in _NEGATED_OPS:
|
||||
return {field: {_NEGATED_OPS[op]: value}}
|
||||
return None
|
||||
|
||||
|
||||
def _hash_to_mongo_filter(query_hash) -> dict | None:
|
||||
"""Translate a TinyDB query hash tuple to a MongoDB filter document.
|
||||
|
||||
Returns ``None`` when the query construct cannot be expressed as a native
|
||||
MongoDB filter, signalling that the caller should fall back to in-memory
|
||||
TinyDB evaluation.
|
||||
"""
|
||||
if not isinstance(query_hash, tuple) or len(query_hash) == 0:
|
||||
return None
|
||||
|
||||
op = query_hash[0]
|
||||
|
||||
if op in _MONGO_OP_MAP:
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
value = query_hash[2]
|
||||
return {field: {_MONGO_OP_MAP[op]: value}}
|
||||
|
||||
if op == 'exists':
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
return {field: {'$exists': True}}
|
||||
|
||||
if op == 'one_of':
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
return {field: {'$in': list(query_hash[2])}}
|
||||
|
||||
if op == 'any':
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
return {field: {'$in': list(query_hash[2])}}
|
||||
|
||||
if op == 'all':
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
return {field: {'$all': list(query_hash[2])}}
|
||||
|
||||
if op == 'matches':
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
# TinyDB matches() anchors the regex at the start of the string.
|
||||
return {field: {'$regex': f'^{query_hash[2]}'}}
|
||||
|
||||
if op == 'search':
|
||||
field = _field_to_mongo(query_hash[1])
|
||||
return {field: {'$regex': query_hash[2]}}
|
||||
|
||||
if op == 'and':
|
||||
merged: dict = {}
|
||||
for sub_hash in query_hash[1]:
|
||||
sub = _hash_to_mongo_filter(sub_hash)
|
||||
if sub is None:
|
||||
return None
|
||||
for field, inner in sub.items():
|
||||
if field in merged:
|
||||
if isinstance(merged[field], dict) and isinstance(inner, dict):
|
||||
merged[field].update(inner)
|
||||
elif isinstance(merged[field], list) and isinstance(inner, list):
|
||||
merged[field].extend(inner)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
merged[field] = (
|
||||
dict(inner) if isinstance(inner, dict)
|
||||
else list(inner) if isinstance(inner, list)
|
||||
else inner
|
||||
)
|
||||
return merged
|
||||
|
||||
if op == 'or':
|
||||
clauses = [_hash_to_mongo_filter(sub_hash) for sub_hash in query_hash[1]]
|
||||
if any(c is None for c in clauses):
|
||||
return None
|
||||
return {'$or': sorted(clauses, key=_stable_clause_key)}
|
||||
|
||||
if op == 'not':
|
||||
inner = _hash_to_mongo_filter(query_hash[1])
|
||||
if inner is None:
|
||||
return None
|
||||
negated = _negate_condition(inner)
|
||||
if negated is not None:
|
||||
return negated
|
||||
return None
|
||||
|
||||
# Unsupported operation (test, fragment, noop, etc.) -> fall back.
|
||||
return None
|
||||
|
||||
|
||||
def _query_to_mongo_filter(query) -> dict | None:
|
||||
"""Translate a TinyDB QueryInstance to a MongoDB filter, if possible."""
|
||||
if isinstance(query, dict):
|
||||
return query
|
||||
if isinstance(query, QueryInstance):
|
||||
return _hash_to_mongo_filter(query._hash)
|
||||
return None
|
||||
|
||||
|
||||
def _evaluate_in_memory(docs, query) -> list:
|
||||
"""Evaluate a TinyDB query against an in-memory list of documents."""
|
||||
if callable(query):
|
||||
return [doc for doc in docs if query(doc)]
|
||||
return docs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MongoDB-backed table wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MongoLockedTable:
|
||||
"""Drop-in replacement for ``LockedTable`` that delegates to MongoDB.
|
||||
|
||||
The adapter preserves the existing table API while mapping the model
|
||||
``id`` field to MongoDB's ``_id`` field on reads and writes.
|
||||
"""
|
||||
|
||||
def __init__(self, collection_name: str):
|
||||
self.collection_name = collection_name
|
||||
|
||||
def _collection(self):
|
||||
client = get_mongo_client()
|
||||
return client[_mongo_db_name][self.collection_name]
|
||||
|
||||
@staticmethod
|
||||
def _doc_to_mongo(doc: dict) -> dict:
|
||||
"""Store a copy of ``doc`` with ``id`` promoted to MongoDB ``_id``.
|
||||
|
||||
This avoids storing both ``_id`` and ``id`` with identical values.
|
||||
The original ``id`` field is removed from the stored document.
|
||||
"""
|
||||
if doc is None:
|
||||
return None
|
||||
d = dict(doc)
|
||||
if 'id' in d:
|
||||
d['_id'] = d.pop('id')
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def _doc_from_mongo(doc: dict):
|
||||
"""Return a TinyDB-compatible Document with ``doc_id`` set to ``_id``.
|
||||
|
||||
Restores the model ``id`` field from MongoDB's ``_id`` and exposes
|
||||
TinyDB's ``doc_id`` attribute so callers that rely on it continue to
|
||||
work.
|
||||
"""
|
||||
if doc is None:
|
||||
return None
|
||||
d = dict(doc)
|
||||
doc_id = d.pop('_id', None)
|
||||
if doc_id is not None:
|
||||
d['id'] = doc_id
|
||||
return Document(d, doc_id=doc_id)
|
||||
|
||||
def _mongo_filter(self, cond):
|
||||
"""Translate a TinyDB query or dict to a MongoDB filter."""
|
||||
return _query_to_mongo_filter(cond)
|
||||
|
||||
def all(self):
|
||||
return [self._doc_from_mongo(doc) for doc in self._collection().find({})]
|
||||
|
||||
def search(self, cond):
|
||||
mongo_filter = self._mongo_filter(cond)
|
||||
if mongo_filter is not None:
|
||||
cursor = self._collection().find(mongo_filter)
|
||||
return [self._doc_from_mongo(doc) for doc in cursor]
|
||||
|
||||
# Fallback: fetch all and evaluate the TinyDB query in Python.
|
||||
docs = list(self._collection().find({}))
|
||||
matched = _evaluate_in_memory(
|
||||
[self._doc_from_mongo(doc) for doc in docs], cond
|
||||
)
|
||||
return matched
|
||||
|
||||
def get(self, cond):
|
||||
mongo_filter = self._mongo_filter(cond)
|
||||
if mongo_filter is not None:
|
||||
doc = self._collection().find_one(mongo_filter)
|
||||
return self._doc_from_mongo(doc)
|
||||
|
||||
docs = list(self._collection().find({}))
|
||||
for doc in docs:
|
||||
d = self._doc_from_mongo(doc)
|
||||
if callable(cond) and cond(d):
|
||||
return d
|
||||
return None
|
||||
|
||||
def insert(self, document: dict):
|
||||
doc = self._doc_to_mongo(document)
|
||||
result = self._collection().insert_one(doc)
|
||||
return str(result.inserted_id)
|
||||
|
||||
def insert_multiple(self, documents: list):
|
||||
if not documents:
|
||||
return []
|
||||
docs = [self._doc_to_mongo(d) for d in documents]
|
||||
result = self._collection().insert_many(docs)
|
||||
return [str(iid) for iid in result.inserted_ids]
|
||||
|
||||
def update(self, fields, cond=None, doc_ids=None):
|
||||
is_callable = callable(fields)
|
||||
|
||||
if doc_ids is not None:
|
||||
mongo_filter = {'_id': {'$in': list(doc_ids)}}
|
||||
target_ids = [str(did) for did in doc_ids]
|
||||
if not target_ids:
|
||||
return []
|
||||
|
||||
if is_callable:
|
||||
# Fetch, apply callable in-memory, and replace each document.
|
||||
updated_ids = []
|
||||
for doc in self._collection().find(mongo_filter):
|
||||
d = self._doc_from_mongo(doc)
|
||||
fields(d)
|
||||
new_doc = self._doc_to_mongo(d)
|
||||
new_doc.pop('_id', None)
|
||||
self._collection().update_one(
|
||||
{'_id': doc['_id']}, {'$set': new_doc}
|
||||
)
|
||||
updated_ids.append(str(doc['_id']))
|
||||
return updated_ids
|
||||
|
||||
update_doc = self._doc_to_mongo(fields) or {}
|
||||
update_doc.pop('_id', None)
|
||||
update_doc.pop('id', None)
|
||||
if update_doc:
|
||||
self._collection().update_many(mongo_filter, {'$set': update_doc})
|
||||
return target_ids
|
||||
|
||||
mongo_filter = self._mongo_filter(cond)
|
||||
if mongo_filter is not None and not is_callable:
|
||||
update_doc = self._doc_to_mongo(fields) or {}
|
||||
update_doc.pop('_id', None)
|
||||
update_doc.pop('id', None)
|
||||
target_ids = [
|
||||
str(doc['_id'])
|
||||
for doc in self._collection().find(mongo_filter, {'_id': 1})
|
||||
]
|
||||
if target_ids and update_doc:
|
||||
self._collection().update_many(
|
||||
mongo_filter, {'$set': update_doc}
|
||||
)
|
||||
return target_ids
|
||||
|
||||
# Fallback: evaluate the query in-memory and update one at a time.
|
||||
docs = list(self._collection().find({}))
|
||||
updated_ids = []
|
||||
for doc in docs:
|
||||
d = self._doc_from_mongo(doc)
|
||||
match = cond(d) if callable(cond) else (mongo_filter is not None)
|
||||
if not match:
|
||||
continue
|
||||
if is_callable:
|
||||
fields(d)
|
||||
new_doc = self._doc_to_mongo(d)
|
||||
new_doc.pop('_id', None)
|
||||
self._collection().update_one(
|
||||
{'_id': doc['_id']}, {'$set': new_doc}
|
||||
)
|
||||
else:
|
||||
update_doc = self._doc_to_mongo(fields) or {}
|
||||
update_doc.pop('_id', None)
|
||||
update_doc.pop('id', None)
|
||||
if update_doc:
|
||||
self._collection().update_one(
|
||||
{'_id': doc['_id']}, {'$set': update_doc}
|
||||
)
|
||||
updated_ids.append(str(doc['_id']))
|
||||
return updated_ids
|
||||
|
||||
def remove(self, cond):
|
||||
mongo_filter = self._mongo_filter(cond)
|
||||
if mongo_filter is not None:
|
||||
target_ids = [
|
||||
str(doc['_id'])
|
||||
for doc in self._collection().find(mongo_filter, {'_id': 1})
|
||||
]
|
||||
if target_ids:
|
||||
self._collection().delete_many(mongo_filter)
|
||||
return target_ids
|
||||
|
||||
# Fallback: evaluate the query in-memory and delete one at a time.
|
||||
docs = list(self._collection().find({}))
|
||||
removed_ids = []
|
||||
for doc in docs:
|
||||
d = self._doc_from_mongo(doc)
|
||||
if callable(cond) and cond(d):
|
||||
self._collection().delete_one({'_id': doc['_id']})
|
||||
removed_ids.append(str(doc['_id']))
|
||||
return removed_ids
|
||||
|
||||
def truncate(self):
|
||||
self._collection().delete_many({})
|
||||
|
||||
def close(self):
|
||||
# MongoDB clients are shared and long-lived; nothing to close here.
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_table(json_path: str, collection_name: str):
|
||||
if USE_MONGODB:
|
||||
return MongoLockedTable(collection_name)
|
||||
db = TinyDB(json_path, indent=2)
|
||||
return LockedTable(db)
|
||||
|
||||
|
||||
# Setup DB files next to this module
|
||||
|
||||
child_path = os.path.join(base_dir, 'children.json')
|
||||
@@ -86,46 +470,116 @@ refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
|
||||
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
|
||||
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
|
||||
|
||||
# Use separate TinyDB instances/files for each collection
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
_task_db = TinyDB(task_path, indent=2)
|
||||
_routine_db = TinyDB(routine_path, indent=2)
|
||||
_routine_items_db = TinyDB(routine_items_path, indent=2)
|
||||
_routine_schedules_db = TinyDB(routine_schedules_path, indent=2)
|
||||
_routine_extensions_db = TinyDB(routine_extensions_path, indent=2)
|
||||
_reward_db = TinyDB(reward_path, indent=2)
|
||||
_image_db = TinyDB(image_path, indent=2)
|
||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||
_pending_confirmations_db = TinyDB(pending_confirmations_path, indent=2)
|
||||
_users_db = TinyDB(users_path, indent=2)
|
||||
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
|
||||
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
|
||||
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
|
||||
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
|
||||
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
|
||||
_push_subscriptions_db = TinyDB(push_subscriptions_path, indent=2)
|
||||
_digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2)
|
||||
# Expose table objects backed by TinyDB or MongoDB based on USE_MONGODB
|
||||
child_db = _make_table(child_path, 'children')
|
||||
task_db = _make_table(task_path, 'tasks')
|
||||
routine_db = _make_table(routine_path, 'routines')
|
||||
routine_items_db = _make_table(routine_items_path, 'routine_items')
|
||||
routine_schedules_db = _make_table(routine_schedules_path, 'routine_schedules')
|
||||
routine_extensions_db = _make_table(routine_extensions_path, 'routine_extensions')
|
||||
reward_db = _make_table(reward_path, 'rewards')
|
||||
image_db = _make_table(image_path, 'images')
|
||||
pending_reward_db = _make_table(pending_reward_path, 'pending_rewards')
|
||||
pending_confirmations_db = _make_table(pending_confirmations_path, 'pending_confirmations')
|
||||
users_db = _make_table(users_path, 'users')
|
||||
tracking_events_db = _make_table(tracking_events_path, 'tracking_events')
|
||||
child_overrides_db = _make_table(child_overrides_path, 'child_overrides')
|
||||
chore_schedules_db = _make_table(chore_schedules_path, 'chore_schedules')
|
||||
task_extensions_db = _make_table(task_extensions_path, 'task_extensions')
|
||||
refresh_tokens_db = _make_table(refresh_tokens_path, 'refresh_tokens')
|
||||
push_subscriptions_db = _make_table(push_subscriptions_path, 'push_subscriptions')
|
||||
digest_action_tokens_db = _make_table(digest_action_tokens_path, 'digest_action_tokens')
|
||||
|
||||
# Expose table objects wrapped with locking
|
||||
child_db = LockedTable(_child_db)
|
||||
task_db = LockedTable(_task_db)
|
||||
routine_db = LockedTable(_routine_db)
|
||||
routine_items_db = LockedTable(_routine_items_db)
|
||||
routine_schedules_db = LockedTable(_routine_schedules_db)
|
||||
routine_extensions_db = LockedTable(_routine_extensions_db)
|
||||
reward_db = LockedTable(_reward_db)
|
||||
image_db = LockedTable(_image_db)
|
||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||
pending_confirmations_db = LockedTable(_pending_confirmations_db)
|
||||
users_db = LockedTable(_users_db)
|
||||
tracking_events_db = LockedTable(_tracking_events_db)
|
||||
child_overrides_db = LockedTable(_child_overrides_db)
|
||||
chore_schedules_db = LockedTable(_chore_schedules_db)
|
||||
task_extensions_db = LockedTable(_task_extensions_db)
|
||||
refresh_tokens_db = LockedTable(_refresh_tokens_db)
|
||||
push_subscriptions_db = LockedTable(_push_subscriptions_db)
|
||||
digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COLLECTION_INDEXES = {
|
||||
# NOTE: The model ``id`` field is stored as MongoDB's primary key ``_id``,
|
||||
# so no separate unique index on ``id`` is needed. Only secondary indexes
|
||||
# for frequently queried fields are defined here.
|
||||
'children': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'tasks': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'routines': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'routine_items': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'routine_schedules': [],
|
||||
'routine_extensions': [],
|
||||
'rewards': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'images': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'pending_rewards': [
|
||||
{'keys': [('child_id', ASCENDING)]},
|
||||
],
|
||||
'pending_confirmations': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
{'keys': [('child_id', ASCENDING)]},
|
||||
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
|
||||
],
|
||||
'users': [],
|
||||
'tracking_events': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
{'keys': [('child_id', ASCENDING)]},
|
||||
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
|
||||
],
|
||||
'child_overrides': [
|
||||
{'keys': [('child_id', ASCENDING)]},
|
||||
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
|
||||
],
|
||||
'chore_schedules': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
{'keys': [('child_id', ASCENDING)]},
|
||||
],
|
||||
'task_extensions': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
{'keys': [('child_id', ASCENDING)]},
|
||||
],
|
||||
'refresh_tokens': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
|
||||
],
|
||||
'push_subscriptions': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
],
|
||||
'digest_action_tokens': [
|
||||
{'keys': [('user_id', ASCENDING)]},
|
||||
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def ensure_mongodb_indexes(client=None, db_name=None):
|
||||
"""Create required indexes on all MongoDB collections.
|
||||
|
||||
Safe to call repeatedly: MongoDB treats index creation as idempotent.
|
||||
"""
|
||||
if not USE_MONGODB:
|
||||
return
|
||||
|
||||
client = client or get_mongo_client()
|
||||
db_name = db_name or _mongo_db_name
|
||||
db = client[db_name]
|
||||
|
||||
for collection_name, indexes in COLLECTION_INDEXES.items():
|
||||
coll = db[collection_name]
|
||||
for spec in indexes:
|
||||
keys = spec['keys']
|
||||
kwargs = {k: v for k, v in spec.items() if k != 'keys'}
|
||||
coll.create_index(keys, **kwargs)
|
||||
|
||||
|
||||
# Clear test collections at import time so tests start with a clean slate.
|
||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
@@ -145,4 +599,3 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
refresh_tokens_db.truncate()
|
||||
push_subscriptions_db.truncate()
|
||||
digest_action_tokens_db.truncate()
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# python
|
||||
"""Lazy MongoDB client factory and database-name helpers.
|
||||
|
||||
The client is intentionally **not** created at module import. Use
|
||||
``get_mongo_client()`` to obtain a cached singleton. For Gunicorn multi-worker
|
||||
deployments call ``init_mongo_client()`` from a ``post_fork`` hook so each
|
||||
worker process owns its own connection pool rather than inheriting the parent
|
||||
process's client.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pymongo import MongoClient
|
||||
from pymongo.uri_parser import parse_uri
|
||||
|
||||
|
||||
_mongo_client_lock = threading.Lock()
|
||||
_mongo_client = None
|
||||
|
||||
|
||||
def _create_mongo_client():
|
||||
"""Build a fail-fast MongoClient from environment variables."""
|
||||
uri = os.environ.get('MONGO_URI')
|
||||
if not uri:
|
||||
raise RuntimeError(
|
||||
'MONGO_URI environment variable is required when USE_MONGODB=true.'
|
||||
)
|
||||
|
||||
# mongomock is used for unit/integration tests without a real server.
|
||||
if uri.lower().startswith('mongomock') or uri.lower() == 'mongomock':
|
||||
try:
|
||||
import mongomock
|
||||
except ImportError as exc: # pragma: no cover - test dependency
|
||||
raise RuntimeError(
|
||||
'mongomock is required for test MongoDB mode. '
|
||||
'Install it with: pip install mongomock'
|
||||
) from exc
|
||||
return mongomock.MongoClient()
|
||||
|
||||
return MongoClient(
|
||||
uri,
|
||||
serverSelectionTimeoutMS=5000,
|
||||
connectTimeoutMS=5000,
|
||||
maxPoolSize=20,
|
||||
)
|
||||
|
||||
|
||||
def init_mongo_client():
|
||||
"""Create a fresh MongoClient and store it as the process singleton.
|
||||
|
||||
Call this from a Gunicorn ``post_fork`` hook so each worker process gets
|
||||
its own client after forking. It can also be called in tests to reset the
|
||||
shared client to a known state.
|
||||
"""
|
||||
global _mongo_client
|
||||
with _mongo_client_lock:
|
||||
_mongo_client = _create_mongo_client()
|
||||
return _mongo_client
|
||||
|
||||
|
||||
def get_mongo_client():
|
||||
"""Return the cached process-level MongoClient, creating it lazily once."""
|
||||
global _mongo_client
|
||||
if _mongo_client is None:
|
||||
with _mongo_client_lock:
|
||||
if _mongo_client is None:
|
||||
_mongo_client = _create_mongo_client()
|
||||
return _mongo_client
|
||||
|
||||
|
||||
def _db_name_from_uri(uri: str) -> str | None:
|
||||
"""Extract the database name from a MongoDB connection URI, if present."""
|
||||
if not uri or uri.lower().startswith('mongomock'):
|
||||
return None
|
||||
try:
|
||||
parsed = parse_uri(uri)
|
||||
return parsed.get('database') or None
|
||||
except Exception:
|
||||
# Fallback to a simple path-based parse for non-standard URIs.
|
||||
try:
|
||||
path = urlparse(uri).path
|
||||
return path.lstrip('/') or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_mongo_db_name() -> str:
|
||||
"""Resolve the MongoDB database name from env vars or the connection URI.
|
||||
|
||||
Precedence:
|
||||
1. ``MONGO_DB_NAME`` environment variable.
|
||||
2. Database name parsed from ``MONGO_URI``.
|
||||
3. Default based on ``DATA_ENV`` / ``DB_ENV``:
|
||||
* prod -> ``chore_db``
|
||||
* e2e -> ``chore_db_e2e``
|
||||
* test -> ``chore_db_test``
|
||||
"""
|
||||
env_name = os.environ.get('MONGO_DB_NAME')
|
||||
if env_name:
|
||||
return env_name
|
||||
|
||||
uri = os.environ.get('MONGO_URI', '')
|
||||
db_name = _db_name_from_uri(uri)
|
||||
if db_name:
|
||||
return db_name
|
||||
|
||||
env = (os.environ.get('DATA_ENV') or os.environ.get('DB_ENV', 'prod')).lower()
|
||||
if env == 'prod':
|
||||
return 'chore_db'
|
||||
if env == 'e2e':
|
||||
return 'chore_db_e2e'
|
||||
return 'chore_db_test'
|
||||
+18
-4
@@ -1,4 +1,5 @@
|
||||
"""Helper functions for tracking events database operations."""
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from tinydb import Query
|
||||
@@ -8,6 +9,10 @@ from models.tracking_event import TrackingEvent, EntityType, ActionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Monotonic sequence used as a deterministic tiebreaker when tracking events
|
||||
# share the same ``occurred_at``/``created_at`` timestamps (common in tests).
|
||||
_tracking_event_seq = itertools.count()
|
||||
|
||||
|
||||
def insert_tracking_event(event: TrackingEvent) -> str:
|
||||
"""
|
||||
@@ -20,7 +25,9 @@ def insert_tracking_event(event: TrackingEvent) -> str:
|
||||
The event ID
|
||||
"""
|
||||
try:
|
||||
tracking_events_db.insert(event.to_dict())
|
||||
event_dict = event.to_dict()
|
||||
event_dict['_seq'] = next(_tracking_event_seq)
|
||||
tracking_events_db.insert(event_dict)
|
||||
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
|
||||
return event.id
|
||||
except Exception as e:
|
||||
@@ -61,8 +68,12 @@ def get_tracking_events_by_child(
|
||||
all_results = tracking_events_db.search(query_condition)
|
||||
total = len(all_results)
|
||||
|
||||
# Sort by occurred_at desc, then created_at desc
|
||||
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
||||
# Sort by occurred_at desc, then created_at desc, then _seq desc for
|
||||
# deterministic ordering when timestamps collide (common in fast tests).
|
||||
all_results.sort(
|
||||
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
paginated = all_results[offset:offset + limit]
|
||||
events = [TrackingEvent.from_dict(r) for r in paginated]
|
||||
@@ -99,7 +110,10 @@ def get_tracking_events_by_user(
|
||||
all_results = tracking_events_db.search(query_condition)
|
||||
total = len(all_results)
|
||||
|
||||
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
||||
all_results.sort(
|
||||
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
paginated = all_results[offset:offset + limit]
|
||||
events = [TrackingEvent.from_dict(r) for r in paginated]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Gunicorn configuration for the chore/reward Flask backend.
|
||||
|
||||
This file is automatically loaded by Gunicorn when it is started from the
|
||||
backend directory. It ensures each worker process creates its own MongoDB
|
||||
client after forking, avoiding shared socket/file-descriptor issues.
|
||||
"""
|
||||
|
||||
|
||||
def post_fork(server, worker):
|
||||
"""Reinitialize the MongoDB client in each worker process after forking."""
|
||||
try:
|
||||
from db.mongo_client import init_mongo_client
|
||||
init_mongo_client()
|
||||
except Exception:
|
||||
# If MongoDB is not configured (USE_MONGODB=false), there is no client
|
||||
# to initialize; ignore the error silently.
|
||||
pass
|
||||
+9
-1
@@ -2,6 +2,7 @@ import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
from api.admin_api import admin_api
|
||||
@@ -26,6 +27,7 @@ from api.digest_action_api import digest_action_api
|
||||
from config.version import get_full_version
|
||||
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from db.db import ensure_mongodb_indexes
|
||||
from events.broadcaster import Broadcaster
|
||||
from events.sse import sse_response_for_user, send_to_user
|
||||
from api.utils import get_current_user_id
|
||||
@@ -34,6 +36,13 @@ from utils.chore_expiry_notification_scheduler import start_chore_expiry_notific
|
||||
from utils.digest_scheduler import start_digest_scheduler
|
||||
from utils.state_expiry_scheduler import start_state_expiry_scheduler
|
||||
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Ensure MongoDB indexes exist when running against MongoDB.
|
||||
ensure_mongodb_indexes()
|
||||
|
||||
# Configure logging once at application startup
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -143,7 +152,6 @@ def start_background_threads():
|
||||
broadcaster.daemon = True
|
||||
broadcaster.start()
|
||||
|
||||
# TODO: implement users
|
||||
initializeImages()
|
||||
createDefaultTasks()
|
||||
createDefaultRewards()
|
||||
|
||||
@@ -9,6 +9,7 @@ class RefreshToken(BaseModel):
|
||||
token_family: str = ''
|
||||
expires_at: str = ''
|
||||
is_used: bool = False
|
||||
rotated_at: str | None = None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -18,6 +19,7 @@ class RefreshToken(BaseModel):
|
||||
'token_family': self.token_family,
|
||||
'expires_at': self.expires_at,
|
||||
'is_used': self.is_used,
|
||||
'rotated_at': self.rotated_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -31,4 +33,5 @@ class RefreshToken(BaseModel):
|
||||
token_family=data.get('token_family', ''),
|
||||
expires_at=data.get('expires_at', ''),
|
||||
is_used=data.get('is_used', False),
|
||||
rotated_at=data.get('rotated_at'),
|
||||
)
|
||||
|
||||
@@ -25,6 +25,8 @@ class User(BaseModel):
|
||||
timezone: str | None = None
|
||||
email_digest_enabled: bool = True
|
||||
push_notifications_enabled: bool = True
|
||||
tutorial_enabled: bool = True
|
||||
tutorial_progress: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -51,6 +53,8 @@ class User(BaseModel):
|
||||
timezone=d.get('timezone'),
|
||||
email_digest_enabled=d.get('email_digest_enabled', True),
|
||||
push_notifications_enabled=d.get('push_notifications_enabled', True),
|
||||
tutorial_enabled=d.get('tutorial_enabled', True),
|
||||
tutorial_progress=d.get('tutorial_progress', {}) or {},
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -82,5 +86,7 @@ class User(BaseModel):
|
||||
'timezone': self.timezone,
|
||||
'email_digest_enabled': self.email_digest_enabled,
|
||||
'push_notifications_enabled': self.push_notifications_enabled,
|
||||
'tutorial_enabled': self.tutorial_enabled,
|
||||
'tutorial_progress': self.tutorial_progress,
|
||||
})
|
||||
return base
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,203 @@
|
||||
# python
|
||||
"""
|
||||
Migrate existing TinyDB JSON files into MongoDB.
|
||||
|
||||
Usage:
|
||||
cd backend
|
||||
python -m scripts.migrate_to_mongodb [--dry-run]
|
||||
|
||||
The script reads files from ``data/db/`` (or ``test_data/db/`` when
|
||||
``DB_ENV=test``), maps each record's ``id`` field to MongoDB's ``_id`` field,
|
||||
and inserts the records idempotently. TinyDB files are backed up to
|
||||
``<db_dir>/backups/<timestamp>/`` before the first migration run.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from config.paths import get_database_dir
|
||||
from db.db import COLLECTION_INDEXES, ensure_mongodb_indexes
|
||||
from db.mongo_client import get_mongo_client, get_mongo_db_name
|
||||
|
||||
|
||||
# Map TinyDB JSON filenames to MongoDB collection names.
|
||||
COLLECTION_FILE_MAP = {
|
||||
'children.json': 'children',
|
||||
'tasks.json': 'tasks',
|
||||
'routines.json': 'routines',
|
||||
'routine_items.json': 'routine_items',
|
||||
'routine_schedules.json': 'routine_schedules',
|
||||
'routine_extensions.json': 'routine_extensions',
|
||||
'rewards.json': 'rewards',
|
||||
'images.json': 'images',
|
||||
'pending_rewards.json': 'pending_rewards',
|
||||
'pending_confirmations.json': 'pending_confirmations',
|
||||
'users.json': 'users',
|
||||
'tracking_events.json': 'tracking_events',
|
||||
'child_overrides.json': 'child_overrides',
|
||||
'chore_schedules.json': 'chore_schedules',
|
||||
'task_extensions.json': 'task_extensions',
|
||||
'refresh_tokens.json': 'refresh_tokens',
|
||||
'push_subscriptions.json': 'push_subscriptions',
|
||||
'digest_action_tokens.json': 'digest_action_tokens',
|
||||
}
|
||||
|
||||
|
||||
def _load_tinydb_records(path: str) -> list[dict]:
|
||||
"""Load all records from a TinyDB JSON file."""
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
default_table = data.get('_default', {})
|
||||
return list(default_table.values())
|
||||
|
||||
|
||||
def _doc_to_mongo(doc: dict) -> dict:
|
||||
"""Map the model ``id`` field to MongoDB's ``_id`` field.
|
||||
|
||||
The original ``id`` field is removed so documents do not store both
|
||||
``_id`` and ``id`` with identical values.
|
||||
"""
|
||||
mongo_doc = dict(doc)
|
||||
if 'id' in mongo_doc:
|
||||
mongo_doc['_id'] = mongo_doc.pop('id')
|
||||
return mongo_doc
|
||||
|
||||
|
||||
def migrate(dry_run: bool = False) -> dict:
|
||||
"""Migrate TinyDB files to MongoDB and return a per-collection summary."""
|
||||
db_dir = get_database_dir()
|
||||
if not os.path.isdir(db_dir):
|
||||
raise FileNotFoundError(f'Database directory does not exist: {db_dir}')
|
||||
|
||||
client = get_mongo_client()
|
||||
db_name = get_mongo_db_name()
|
||||
db = client[db_name]
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_dir = os.path.join(db_dir, 'backups', timestamp)
|
||||
|
||||
if not dry_run:
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ensure_mongodb_indexes(client=client, db_name=db_name)
|
||||
|
||||
summary: dict[str, dict] = {}
|
||||
|
||||
for filename, collection_name in COLLECTION_FILE_MAP.items():
|
||||
path = os.path.join(db_dir, filename)
|
||||
if not os.path.exists(path):
|
||||
summary[collection_name] = {
|
||||
'source_file': filename,
|
||||
'total': 0,
|
||||
'migrated': 0,
|
||||
'skipped': 0,
|
||||
'status': 'missing',
|
||||
}
|
||||
continue
|
||||
|
||||
records = _load_tinydb_records(path)
|
||||
|
||||
if not dry_run:
|
||||
shutil.copy2(path, backup_dir)
|
||||
|
||||
collection = db[collection_name]
|
||||
to_insert: list[dict] = []
|
||||
skipped = 0
|
||||
|
||||
for record in records:
|
||||
doc_id = record.get('id')
|
||||
if not doc_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
existing = collection.find_one({'_id': doc_id})
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
to_insert.append(_doc_to_mongo(record))
|
||||
|
||||
if not dry_run and to_insert:
|
||||
try:
|
||||
collection.insert_many(to_insert, ordered=False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(
|
||||
f' Warning: error inserting into {collection_name}: {exc}',
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
summary[collection_name] = {
|
||||
'source_file': filename,
|
||||
'total': len(records),
|
||||
'migrated': len(to_insert),
|
||||
'skipped': skipped,
|
||||
'status': 'migrated' if not dry_run else 'dry-run',
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _print_summary(summary: dict) -> None:
|
||||
"""Print a human-readable migration summary."""
|
||||
print('\nMigration Summary')
|
||||
print('-' * 70)
|
||||
print(f'{"Collection":<30}{"Total":>8}{"Migrated":>10}{"Skipped":>10}{"Status":>10}')
|
||||
print('-' * 70)
|
||||
total_records = 0
|
||||
total_migrated = 0
|
||||
total_skipped = 0
|
||||
for collection_name, info in summary.items():
|
||||
print(
|
||||
f'{collection_name:<30}'
|
||||
f'{info["total"]:>8}'
|
||||
f'{info["migrated"]:>10}'
|
||||
f'{info["skipped"]:>10}'
|
||||
f'{info["status"]:>10}'
|
||||
)
|
||||
total_records += info['total']
|
||||
total_migrated += info['migrated']
|
||||
total_skipped += info['skipped']
|
||||
print('-' * 70)
|
||||
print(
|
||||
f'{"TOTAL":<30}'
|
||||
f'{total_records:>8}'
|
||||
f'{total_migrated:>10}'
|
||||
f'{total_skipped:>10}'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Migrate TinyDB JSON files to MongoDB.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Analyze files and report counts without writing to MongoDB.',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.environ.get('USE_MONGODB', 'true').lower() != 'true':
|
||||
print('Set USE_MONGODB=true to run the migration.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.environ.get('MONGO_URI'):
|
||||
print('MONGO_URI is required when USE_MONGODB=true.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.dry_run:
|
||||
print('Dry run: no data will be written to MongoDB.')
|
||||
|
||||
summary = migrate(dry_run=args.dry_run)
|
||||
_print_summary(summary)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run the MongoDB adapter integration tests against a local Docker MongoDB container.
|
||||
|
||||
.DESCRIPTION
|
||||
Starts a temporary MongoDB container, runs a targeted pytest suite with
|
||||
USE_MONGODB=true, then stops and removes the container.
|
||||
|
||||
.EXAMPLE
|
||||
cd backend
|
||||
.\scripts\run_integration_tests.ps1
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ContainerName = 'chore-db-integration-test',
|
||||
[int]$HostPort = 27017,
|
||||
[string]$Image = 'mongo:8',
|
||||
[string]$DbName = 'chore_db_test',
|
||||
[string]$TestPath = 'tests/test_mongo_adapter.py'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$mongoUri = "mongodb://localhost:${HostPort}/${DbName}"
|
||||
|
||||
function Test-ContainerRunning {
|
||||
$containers = docker ps --filter "name=$ContainerName" --format '{{.Names}}' 2>$null
|
||||
return $containers -contains $ContainerName
|
||||
}
|
||||
|
||||
function Wait-MongoReady {
|
||||
param([int]$TimeoutSeconds = 30)
|
||||
$start = Get-Date
|
||||
while (((Get-Date) - $start).TotalSeconds -lt $TimeoutSeconds) {
|
||||
try {
|
||||
$null = docker exec $ContainerName mongosh --eval 'db.adminCommand({ ping: 1 })' --quiet 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# Container or mongosh may not be ready yet.
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
throw "MongoDB container did not become ready within ${TimeoutSeconds} seconds."
|
||||
}
|
||||
|
||||
# Clean up any leftover container from a previous aborted run.
|
||||
if (Test-ContainerRunning) {
|
||||
Write-Host "Removing existing container '$ContainerName'..."
|
||||
docker rm -f $ContainerName | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Starting MongoDB container '$ContainerName' on port $HostPort..."
|
||||
docker run -d `
|
||||
--name $ContainerName `
|
||||
-p "${HostPort}:27017" `
|
||||
$Image | Out-Null
|
||||
|
||||
try {
|
||||
Wait-MongoReady
|
||||
Write-Host "MongoDB is ready. Running integration tests..."
|
||||
|
||||
$env:USE_MONGODB = 'true'
|
||||
$env:MONGO_URI = $mongoUri
|
||||
$env:MONGO_DB_NAME = $DbName
|
||||
$env:DB_ENV = 'test'
|
||||
$env:DATA_ENV = 'test'
|
||||
|
||||
pytest $TestPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Integration tests failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
} finally {
|
||||
Write-Host "Stopping and removing container '$ContainerName'..."
|
||||
docker rm -f $ContainerName | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Integration tests complete."
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
os.environ['DB_ENV'] = 'test'
|
||||
os.environ['USE_MONGODB'] = 'true'
|
||||
os.environ['MONGO_URI'] = 'mongomock'
|
||||
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
|
||||
os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret')
|
||||
@@ -18,5 +20,11 @@ TEST_REFRESH_TOKEN_EXPIRY_DAYS = 90
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def set_test_db_env():
|
||||
os.environ['DB_ENV'] = 'test'
|
||||
os.environ['USE_MONGODB'] = 'true'
|
||||
os.environ['MONGO_URI'] = 'mongomock'
|
||||
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
|
||||
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)
|
||||
# Ensure indexes are created once for the test session. This is safe to
|
||||
# call repeatedly because MongoDB treats index creation as idempotent.
|
||||
from db.db import ensure_mongodb_indexes
|
||||
ensure_mongodb_indexes()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask import Flask
|
||||
from api.auth_api import auth_api
|
||||
from api.auth_api import auth_api, _hash_token
|
||||
from db.db import users_db, refresh_tokens_db
|
||||
from tinydb import Query
|
||||
from models.user import User
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||
|
||||
@pytest.fixture
|
||||
@@ -189,3 +189,244 @@ def test_migration_script_hashes_plain_text_passwords():
|
||||
# Check user2 password unchanged
|
||||
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
||||
assert user2_dict['password'] == already_hashed
|
||||
|
||||
|
||||
def _extract_cookie_value(response, cookie_name):
|
||||
"""Extract a raw cookie value from a response's Set-Cookie headers."""
|
||||
for cookie in response.headers.getlist('Set-Cookie'):
|
||||
if cookie.startswith(f'{cookie_name}='):
|
||||
return cookie.split(f'{cookie_name}=', 1)[1].split(';', 1)[0]
|
||||
return None
|
||||
|
||||
|
||||
def _create_verified_user(email, password):
|
||||
"""Insert a verified user with the given credentials."""
|
||||
users_db.remove(Query().email == email)
|
||||
user = User(
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
email=email,
|
||||
password=generate_password_hash(password),
|
||||
verified=True,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return user
|
||||
|
||||
|
||||
def _set_refresh_cookie(client, raw_token):
|
||||
"""
|
||||
Set the refresh token cookie on a test client so it is sent to /auth/refresh.
|
||||
Production uses path='/api/auth' because the frontend calls /api/auth/refresh,
|
||||
but the test fixture exposes the blueprint at /auth/refresh directly.
|
||||
"""
|
||||
client.set_cookie('refresh_token', raw_token, path='/')
|
||||
|
||||
|
||||
def test_refresh_rotates_token(client):
|
||||
"""A successful refresh marks the old token used and issues a new one in the same family."""
|
||||
email = 'refresh-rotate@test.com'
|
||||
password = 'password123'
|
||||
_create_verified_user(email, password)
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_response.status_code == 200
|
||||
old_refresh = _extract_cookie_value(login_response, 'refresh_token')
|
||||
assert old_refresh
|
||||
|
||||
_set_refresh_cookie(client, old_refresh)
|
||||
refresh_response = client.post('/auth/refresh')
|
||||
assert refresh_response.status_code == 200
|
||||
new_refresh = _extract_cookie_value(refresh_response, 'refresh_token')
|
||||
assert new_refresh
|
||||
assert new_refresh != old_refresh
|
||||
|
||||
user_dict = users_db.get(Query().email == email)
|
||||
old_hash = _hash_token(old_refresh)
|
||||
new_hash = _hash_token(new_refresh)
|
||||
|
||||
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||
assert old_record is not None
|
||||
assert old_record['is_used'] is True
|
||||
assert old_record['rotated_at'] is not None
|
||||
|
||||
new_record = refresh_tokens_db.get(Query().token_hash == new_hash)
|
||||
assert new_record is not None
|
||||
assert new_record['is_used'] is False
|
||||
assert new_record['token_family'] == old_record['token_family']
|
||||
|
||||
|
||||
def test_refresh_reuse_only_invalidates_family(client):
|
||||
"""Replay of a used refresh token only kills its own family, not other devices."""
|
||||
email = 'refresh-family@test.com'
|
||||
password = 'password123'
|
||||
user = _create_verified_user(email, password)
|
||||
|
||||
# Device A logs in
|
||||
client_a = client
|
||||
login_a = client_a.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_a.status_code == 200
|
||||
refresh_a = _extract_cookie_value(login_a, 'refresh_token')
|
||||
|
||||
# Device B logs in (separate client = separate cookie jar)
|
||||
app = client_a.application
|
||||
client_b = app.test_client()
|
||||
login_b = client_b.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_b.status_code == 200
|
||||
refresh_b = _extract_cookie_value(login_b, 'refresh_token')
|
||||
|
||||
assert refresh_a != refresh_b
|
||||
|
||||
# Device A refreshes normally
|
||||
_set_refresh_cookie(client_a, refresh_a)
|
||||
refresh_a_response = client_a.post('/auth/refresh')
|
||||
assert refresh_a_response.status_code == 200
|
||||
|
||||
# Capture families before any purge so we can assert afterwards.
|
||||
family_a = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_a))['token_family']
|
||||
family_b = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_b))['token_family']
|
||||
assert family_a != family_b
|
||||
|
||||
# Backdate rotation so the replay is past the grace period and treated as theft.
|
||||
old_hash_a = _hash_token(refresh_a)
|
||||
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
||||
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash_a)
|
||||
|
||||
# Attacker replays device A's old token
|
||||
_set_refresh_cookie(client_a, refresh_a)
|
||||
reuse_response = client_a.post('/auth/refresh')
|
||||
assert reuse_response.status_code == 401
|
||||
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||
|
||||
# Device B's refresh token should still be valid
|
||||
_set_refresh_cookie(client_b, refresh_b)
|
||||
refresh_b_response = client_b.post('/auth/refresh')
|
||||
assert refresh_b_response.status_code == 200
|
||||
|
||||
# Only family A should be purged; family B should remain
|
||||
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||
remaining_families = {t['token_family'] for t in remaining}
|
||||
assert family_a not in remaining_families
|
||||
assert family_b in remaining_families
|
||||
|
||||
|
||||
def test_refresh_reuse_within_grace_period_is_tolerated(client):
|
||||
"""A replay within the grace period is treated as a race condition, not theft."""
|
||||
email = 'refresh-race@test.com'
|
||||
password = 'password123'
|
||||
_create_verified_user(email, password)
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_response.status_code == 200
|
||||
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||
|
||||
# First refresh marks the token as used
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
first_refresh = client.post('/auth/refresh')
|
||||
assert first_refresh.status_code == 200
|
||||
|
||||
# Immediate replay (same legitimate client racing) should succeed
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
race_response = client.post('/auth/refresh')
|
||||
assert race_response.status_code == 200
|
||||
|
||||
# The family should still be valid
|
||||
user_dict = users_db.get(Query().email == email)
|
||||
family = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_token))['token_family']
|
||||
family_tokens = refresh_tokens_db.search(
|
||||
(Query().user_id == user_dict['id']) & (Query().token_family == family)
|
||||
)
|
||||
assert len(family_tokens) >= 1
|
||||
assert any(t['is_used'] is False for t in family_tokens)
|
||||
|
||||
|
||||
def test_refresh_reuse_after_grace_period_invalidates_family(client):
|
||||
"""A replay after the grace period is treated as theft and kills only that family."""
|
||||
email = 'refresh-theft@test.com'
|
||||
password = 'password123'
|
||||
user = _create_verified_user(email, password)
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_response.status_code == 200
|
||||
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||
|
||||
# Refresh once, then backdate the rotation timestamp past the grace period
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
client.post('/auth/refresh')
|
||||
|
||||
old_hash = _hash_token(refresh_token)
|
||||
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||
old_family = old_record['token_family']
|
||||
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
||||
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash)
|
||||
|
||||
# Replay now should be detected as theft
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
reuse_response = client.post('/auth/refresh')
|
||||
assert reuse_response.status_code == 401
|
||||
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||
|
||||
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||
remaining_families = {t['token_family'] for t in remaining}
|
||||
assert old_family not in remaining_families
|
||||
|
||||
|
||||
def test_refresh_reuse_without_rotated_at_invalidates_family(client):
|
||||
"""Legacy used tokens without rotated_at are treated as theft, not race conditions."""
|
||||
email = 'refresh-legacy@test.com'
|
||||
password = 'password123'
|
||||
user = _create_verified_user(email, password)
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_response.status_code == 200
|
||||
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||
|
||||
# Rotate the token, then strip rotated_at to simulate pre-migration data
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
client.post('/auth/refresh')
|
||||
old_hash = _hash_token(refresh_token)
|
||||
refresh_tokens_db.update({'rotated_at': None}, Query().token_hash == old_hash)
|
||||
|
||||
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||
old_family = old_record['token_family']
|
||||
|
||||
# Replay should be treated as theft because rotated_at is missing
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
reuse_response = client.post('/auth/refresh')
|
||||
assert reuse_response.status_code == 401
|
||||
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||
|
||||
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||
remaining_families = {t['token_family'] for t in remaining}
|
||||
assert old_family not in remaining_families
|
||||
|
||||
|
||||
def test_refresh_reuse_with_zero_grace_period(client):
|
||||
"""A grace period of 0 means any replay of a used token is treated as theft."""
|
||||
email = 'refresh-zero-grace@test.com'
|
||||
password = 'password123'
|
||||
user = _create_verified_user(email, password)
|
||||
|
||||
# Configure the app with a 0-second grace period
|
||||
client.application.config['REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS'] = 0
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||
assert login_response.status_code == 200
|
||||
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||
|
||||
# Rotate the token; rotated_at is within the normal default grace period
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
client.post('/auth/refresh')
|
||||
|
||||
old_hash = _hash_token(refresh_token)
|
||||
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||
old_family = old_record['token_family']
|
||||
|
||||
# Immediate replay should still be theft with a 0-second grace period
|
||||
_set_refresh_cookie(client, refresh_token)
|
||||
reuse_response = client.post('/auth/refresh')
|
||||
assert reuse_response.status_code == 401
|
||||
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||
|
||||
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||
remaining_families = {t['token_family'] for t in remaining}
|
||||
assert old_family not in remaining_families
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import os
|
||||
import pytest
|
||||
from tinydb import Query
|
||||
|
||||
from db.db import (
|
||||
MongoLockedTable,
|
||||
_query_to_mongo_filter,
|
||||
child_db,
|
||||
task_db,
|
||||
users_db,
|
||||
refresh_tokens_db,
|
||||
)
|
||||
from db.mongo_client import get_mongo_client, get_mongo_db_name
|
||||
|
||||
|
||||
# All tests in this module require the mongomock-backed MongoDB adapter.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get('USE_MONGODB', 'true').lower() != 'true',
|
||||
reason='MongoDB adapter tests require USE_MONGODB=true',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_mongo_collections():
|
||||
"""Truncate relevant collections before each test."""
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
users_db.truncate()
|
||||
refresh_tokens_db.truncate()
|
||||
yield
|
||||
|
||||
|
||||
class TestQueryTranslation:
|
||||
"""Unit tests for TinyDB Query -> MongoDB filter translation."""
|
||||
|
||||
def test_simple_equality(self):
|
||||
q = Query()
|
||||
assert _query_to_mongo_filter(q.id == 'abc') == {'_id': {'$eq': 'abc'}}
|
||||
|
||||
def test_field_other_than_id(self):
|
||||
q = Query()
|
||||
assert _query_to_mongo_filter(q.user_id == 'u1') == {
|
||||
'user_id': {'$eq': 'u1'}
|
||||
}
|
||||
|
||||
def test_and_query(self):
|
||||
q = Query()
|
||||
mongo_filter = _query_to_mongo_filter(
|
||||
(q.id == 'abc') & (q.user_id == 'u1')
|
||||
)
|
||||
assert mongo_filter == {'_id': {'$eq': 'abc'}, 'user_id': {'$eq': 'u1'}}
|
||||
|
||||
def test_or_query(self):
|
||||
q = Query()
|
||||
mongo_filter = _query_to_mongo_filter(
|
||||
(q.user_id == 'u1') | (q.user_id == None) # noqa: E711
|
||||
)
|
||||
assert mongo_filter == {
|
||||
'$or': [
|
||||
{'user_id': {'$eq': 'u1'}},
|
||||
{'user_id': {'$eq': None}},
|
||||
]
|
||||
}
|
||||
|
||||
def test_and_with_nested_or(self):
|
||||
q = Query()
|
||||
mongo_filter = _query_to_mongo_filter(
|
||||
(q.id == 'abc') & ((q.user_id == 'u1') | (q.user_id == None)) # noqa: E711
|
||||
)
|
||||
assert mongo_filter == {
|
||||
'_id': {'$eq': 'abc'},
|
||||
'$or': [
|
||||
{'user_id': {'$eq': 'u1'}},
|
||||
{'user_id': {'$eq': None}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestMongoCrud:
|
||||
"""CRUD tests against the mongomock-backed MongoLockedTable."""
|
||||
|
||||
def test_insert_maps_id_to_underscore_id(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice', 'age': 8})
|
||||
|
||||
raw = get_mongo_client()[get_mongo_db_name()]['children'].find_one(
|
||||
{'_id': 'c1'}
|
||||
)
|
||||
assert raw is not None
|
||||
assert raw['_id'] == 'c1'
|
||||
assert 'id' not in raw
|
||||
assert raw['name'] == 'Alice'
|
||||
|
||||
def test_get_returns_document_without_underscore_id(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice', 'age': 8})
|
||||
|
||||
doc = child_db.get(Query().id == 'c1')
|
||||
assert doc is not None
|
||||
assert doc['id'] == 'c1'
|
||||
assert doc['name'] == 'Alice'
|
||||
assert '_id' not in doc
|
||||
|
||||
def test_get_none_when_missing(self):
|
||||
assert child_db.get(Query().id == 'missing') is None
|
||||
|
||||
def test_search_with_query(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice', 'user_id': 'u1'})
|
||||
child_db.insert({'id': 'c2', 'name': 'Bob', 'user_id': 'u1'})
|
||||
child_db.insert({'id': 'c3', 'name': 'Carol', 'user_id': 'u2'})
|
||||
|
||||
results = child_db.search(Query().user_id == 'u1')
|
||||
assert len(results) == 2
|
||||
assert {r['id'] for r in results} == {'c1', 'c2'}
|
||||
|
||||
def test_search_with_or(self):
|
||||
task_db.insert({'id': 't1', 'name': 'Default', 'user_id': None})
|
||||
task_db.insert({'id': 't2', 'name': 'User task', 'user_id': 'u1'})
|
||||
|
||||
q = Query()
|
||||
results = task_db.search((q.user_id == 'u1') | (q.user_id == None)) # noqa: E711
|
||||
assert len(results) == 2
|
||||
|
||||
def test_update_modifies_matching_documents(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice', 'points': 0})
|
||||
child_db.insert({'id': 'c2', 'name': 'Bob', 'points': 0})
|
||||
|
||||
modified = child_db.update({'points': 10}, Query().id == 'c1')
|
||||
# TinyDB returns a list of updated document ids; the adapter mirrors that.
|
||||
assert modified == ['c1']
|
||||
|
||||
doc = child_db.get(Query().id == 'c1')
|
||||
assert doc['points'] == 10
|
||||
|
||||
other = child_db.get(Query().id == 'c2')
|
||||
assert other['points'] == 0
|
||||
|
||||
def test_update_does_not_overwrite_id(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||
|
||||
child_db.update({'id': 'c2', 'name': 'Alice Smith'}, Query().id == 'c1')
|
||||
# The id field must remain unchanged; update should have stripped id.
|
||||
assert child_db.get(Query().id == 'c1')['name'] == 'Alice Smith'
|
||||
assert child_db.get(Query().id == 'c2') is None
|
||||
|
||||
def test_remove_deletes_matching_documents(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||
child_db.insert({'id': 'c2', 'name': 'Bob'})
|
||||
|
||||
deleted = child_db.remove(Query().id == 'c1')
|
||||
# TinyDB returns a list of removed document ids; the adapter mirrors that.
|
||||
assert deleted == ['c1']
|
||||
|
||||
assert child_db.get(Query().id == 'c1') is None
|
||||
assert child_db.get(Query().id == 'c2') is not None
|
||||
|
||||
def test_all_returns_all_documents(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||
child_db.insert({'id': 'c2', 'name': 'Bob'})
|
||||
|
||||
docs = child_db.all()
|
||||
assert len(docs) == 2
|
||||
assert all('_id' not in d for d in docs)
|
||||
|
||||
def test_truncate_removes_all_documents(self):
|
||||
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||
child_db.truncate()
|
||||
assert child_db.all() == []
|
||||
|
||||
def test_insert_multiple(self):
|
||||
ids = child_db.insert_multiple([
|
||||
{'id': 'c1', 'name': 'Alice'},
|
||||
{'id': 'c2', 'name': 'Bob'},
|
||||
])
|
||||
assert sorted(ids) == ['c1', 'c2']
|
||||
assert len(child_db.all()) == 2
|
||||
|
||||
def test_unique_token_index(self):
|
||||
refresh_tokens_db.insert({'id': 'r1', 'token': 'abc', 'user_id': 'u1'})
|
||||
refresh_tokens_db.insert({'id': 'r2', 'token': 'def', 'user_id': 'u1'})
|
||||
|
||||
# mongomock does not enforce unique indexes by default, but we verify
|
||||
# both records are readable.
|
||||
assert refresh_tokens_db.get(Query().token == 'abc') is not None
|
||||
assert refresh_tokens_db.get(Query().token == 'def') is not None
|
||||
|
||||
def test_user_id_secondary_index_is_created(self):
|
||||
# Insert and query via the secondary index path used by the app.
|
||||
users_db.insert({'id': 'u1', 'email': 'a@example.com', 'verified': True})
|
||||
users_db.insert({'id': 'u2', 'email': 'b@example.com', 'verified': False})
|
||||
|
||||
found = users_db.search(Query().verified == True) # noqa: E712
|
||||
assert len(found) == 1
|
||||
assert found[0]['id'] == 'u1'
|
||||
|
||||
|
||||
class TestAdapterApi:
|
||||
"""Tests that the adapter exposes the expected LockedTable-compatible API."""
|
||||
|
||||
def test_close_is_noop(self):
|
||||
# Existing cleanup fixtures call ``*_db.close()``; ensure it does not
|
||||
# raise for the MongoDB-backed adapter.
|
||||
child_db.close()
|
||||
@@ -62,7 +62,7 @@ def get_expiring_chores_for_user(
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
local_now = datetime.now(ZoneInfo(tz_str)) if tz_str else now_dt
|
||||
local_now = now_dt.astimezone(ZoneInfo(tz_str)) if tz_str else now_dt
|
||||
except Exception:
|
||||
local_now = now_dt
|
||||
|
||||
@@ -106,10 +106,15 @@ def get_expiring_chores_for_user(
|
||||
continue # Anytime — no expiry
|
||||
|
||||
due_hour, due_minute = due
|
||||
# Build a timezone-aware deadline datetime for today
|
||||
# Build a timezone-aware deadline datetime for the next occurrence of the due time.
|
||||
# Schedules run hourly and may span midnight (e.g. a 23:00 run needs to catch
|
||||
# a chore due at 00:15 the next day), so roll forward a day when the candidate
|
||||
# deadline has already passed.
|
||||
deadline_dt = local_now.replace(
|
||||
hour=due_hour, minute=due_minute, second=0, microsecond=0
|
||||
)
|
||||
if deadline_dt <= local_now:
|
||||
deadline_dt = deadline_dt + timedelta(days=1)
|
||||
|
||||
# Include only when deadline is strictly after now and within window
|
||||
if not (local_now < deadline_dt <= window_end):
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# MongoDB configuration for end-to-end tests.
|
||||
# Uses mongomock so E2E tests do not require a running MongoDB server.
|
||||
USE_MONGODB=true
|
||||
MONGO_URI=mongomock
|
||||
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "reYniSI2OIXUAcXkeBNTaaOD7MJOzNEhwxwSDO42bew",
|
||||
"value": "hW7OJbUjHr4XntQRPtefKNhvvctK014R6pzLz_vyJ8g",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1787504746.987795,
|
||||
"expires": 1792774142.289068,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNDQyY2E4Ni1lNGIzLTRjZjEtYThmYS0zNWJmYmZhNzk5NjYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzk3Mzk1NDZ9.3zqjc9RdG9jpr5wbmhbqYUCvzxUl9d8tcc4EY1-BYJc",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI5MjUwNzY1MS00YjlmLTRkMTMtOTgxYS1hMzA2ZThjY2JmNzciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NDJ9.15hDvKf9u95pMzyeSbrHesFBDGgTwnxV1FD175p72-w",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1779739546.987748,
|
||||
"expires": 1785008942.288088,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1779728746835}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1784998141942}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1779901547148}"
|
||||
"value": "{\"expiresAt\":1785170942553}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "DlW9UoiIvVxuPPp3wfo3qsVP5tlO2Iw2e8TZThWeMDw",
|
||||
"value": "PylNoNcxDjRuoocNg4ZmApg_7OIxp9Jfs7ttsX3C5uA",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1787504746.877459,
|
||||
"expires": 1792435594.088028,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiM2E3ZDA3NjYtYTQyNy00NTQ5LWE0NGEtMWU0ZjUwOGRhZDBhIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc5NzM5NTQ2fQ.jFLpGeJBM7U-x-N1jT-muHjBStXFyeb5oQS0LryWwr0",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiN2UzNjdjMGItMDU3ZS00MWEzLThlNzYtZWZmNDUyMmFmOTU2IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzg0NjcwMzk0fQ.cVkoiVTtH1O7TFqW5yQ9BEBlTq7hR8YEya7FW2b_Yh8",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1779739546.877411,
|
||||
"expires": 1784670394.08796,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1779728746722}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1784659593889}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1779901547028}"
|
||||
"value": "{\"expiresAt\":1784832394248}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "5gEK_iLdGl1BAGfetT8hEf6SJK2lQVbYxQjH0BdKWwU",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1792813397.168935,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkODk0ZjA0OS0xODU3LTQwM2UtYjgzZC1jMTY2NTNmZDU2MmEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTd9.S7frWOrZwXC5xVgtG2RDioXGok04qK_jLCYWvfedD7s",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1785048197.167997,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1785037396813}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1785210197388}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "Xz4WQkQNdryfFsS3GaA09dGDz3yEaRwHxF2VdIh8LwQ",
|
||||
"value": "Ke6qDj_0RYyrMSI1Z9C69DPtvYo26oUUiLZr_pDXZv4",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1787504745.201123,
|
||||
"expires": 1792813392.716776,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJhMWVlZGNkOC1hMGE0LTQyYjMtOWU3ZC01MWRmNGM1ZTFiNTUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzk3Mzk1NDV9.Q2q-xiDjAw8pu3t8ioaawUpGK1__5wBiPZ-vFvXHnmw",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3NTYwZDBlNy1iMmVlLTRiMjktYTYwNi1lZTM3NmIzN2Y3ODciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTJ9.QB4bXo88KC7jr94QSjJI3Y1eVWeU4KNKdHRi1_7zcD0",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1779739545.201078,
|
||||
"expires": 1785048192.71579,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1779728745058}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1785037392502}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1779901545328}"
|
||||
"value": "{\"expiresAt\":1785210192904}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test as setup } from '@playwright/test'
|
||||
import {
|
||||
STORAGE_STATE_TUTORIAL,
|
||||
E2E_TUTORIAL_EMAIL,
|
||||
E2E_TUTORIAL_PASSWORD,
|
||||
E2E_TUTORIAL_PIN,
|
||||
} from './e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
setup('authenticate tutorial user', async ({ page }) => {
|
||||
// Create the isolated tutorial-test user (separate from the main E2E user)
|
||||
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-tutorial-user`)
|
||||
if (!createRes.ok()) {
|
||||
throw new Error(
|
||||
`e2e-create-tutorial-user failed: ${createRes.status()} ${await createRes.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
await page.goto('/auth/login')
|
||||
await page.getByLabel('Email address').fill(E2E_TUTORIAL_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_TUTORIAL_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await page.waitForURL(/\/(parent|child)/)
|
||||
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_TUTORIAL_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
|
||||
} catch {
|
||||
await page.screenshot({ path: 'auth-setup-tutorial-parent-fail.png' })
|
||||
throw new Error(
|
||||
'Tutorial user parent mode not reached after PIN entry. See auth-setup-tutorial-parent-fail.png for details.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: STORAGE_STATE_TUTORIAL })
|
||||
})
|
||||
@@ -3,6 +3,7 @@ export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
|
||||
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
|
||||
export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json'
|
||||
export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json'
|
||||
export const STORAGE_STATE_TUTORIAL = 'e2e/.auth/user-tutorial.json'
|
||||
export const E2E_EMAIL = 'e2e@test.com'
|
||||
export const E2E_PASSWORD = 'E2eTestPass1!'
|
||||
export const E2E_PIN = '1234'
|
||||
@@ -13,3 +14,6 @@ export const E2E_DELETE_PIN = '5678'
|
||||
export const E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||
export const E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||
export const E2E_CC_PIN = '3456'
|
||||
export const E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
|
||||
export const E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
|
||||
export const E2E_TUTORIAL_PIN = '7890'
|
||||
|
||||
@@ -14,6 +14,11 @@ test.describe('Create Child', () => {
|
||||
|
||||
// Navigate to parent list and wait for it to fully load before clicking Add Child
|
||||
await gotoParentList(page)
|
||||
// Dismiss tutorial overlay if present — it intercepts pointer events
|
||||
const skip = page.getByRole('button', { name: 'Cancel' })
|
||||
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skip.click()
|
||||
}
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
})
|
||||
@@ -30,7 +35,7 @@ test.describe('Create Child', () => {
|
||||
test('Reject submission when Name is whitespace only', async ({ page }) => {
|
||||
// 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill(' ')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill(' ')
|
||||
await page.getByLabel('Age').fill('7')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
@@ -39,7 +44,7 @@ test.describe('Create Child', () => {
|
||||
|
||||
test('Reject submission when Age is empty', async ({ page }) => {
|
||||
// 2. Enter 'Charlie', clear Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Charlie')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Charlie')
|
||||
await page.getByLabel('Age').clear()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
@@ -49,7 +54,7 @@ test.describe('Create Child', () => {
|
||||
test('Reject negative age', async ({ page }) => {
|
||||
// 2. Enter 'Dave', enter '-1' - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill('Dave')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Dave')
|
||||
await page.getByLabel('Age').fill('-1')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
@@ -57,7 +62,7 @@ test.describe('Create Child', () => {
|
||||
})
|
||||
|
||||
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const nameInput = page.getByRole('textbox', { name: 'Name' })
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
@@ -85,7 +90,7 @@ test.describe('Create Child', () => {
|
||||
|
||||
test('Reject age greater than 120', async ({ page }) => {
|
||||
// 2. Enter 'Eve', enter '121' in Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Eve')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Eve')
|
||||
await page.getByLabel('Age').fill('121')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
|
||||
@@ -9,6 +9,17 @@ function pushToggle(page: Page) {
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
async function openNotificationsSection(page: Page): Promise<void> {
|
||||
const header = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Notifications' }) })
|
||||
.locator('.section-header')
|
||||
if ((await header.getAttribute('aria-expanded')) === 'false') {
|
||||
await header.click()
|
||||
}
|
||||
await page.locator('#section-notifications').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
test.describe('Push subscription registration', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.3 No subscription POST when notification permission not granted
|
||||
@@ -26,6 +37,7 @@ test.describe('Push subscription registration', () => {
|
||||
})
|
||||
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
const isDisabled = await toggle.isDisabled().catch(() => false)
|
||||
|
||||
@@ -16,6 +16,17 @@ function pushToggle(page: Page) {
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
async function openNotificationsSection(page: Page): Promise<void> {
|
||||
const header = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Notifications' }) })
|
||||
.locator('.section-header')
|
||||
if ((await header.getAttribute('aria-expanded')) === 'false') {
|
||||
await header.click()
|
||||
}
|
||||
await page.locator('#section-notifications').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
test.describe('User Profile Notification Settings', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -24,6 +35,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -34,11 +46,13 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// Set known state: enabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
// Set known state: disabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.reload()
|
||||
await openNotificationsSection(page)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
// Restore default
|
||||
@@ -51,6 +65,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
@@ -64,9 +79,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
|
||||
await digestToggle(page).click()
|
||||
|
||||
// Submit the form (Save button)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Toggles auto-save; wait for the PUT and verify the payload.
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
|
||||
@@ -82,6 +95,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
@@ -94,7 +108,6 @@ test.describe('User Profile Notification Settings', () => {
|
||||
})
|
||||
|
||||
await digestToggle(page).click()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
@@ -110,6 +123,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -121,6 +135,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
}) => {
|
||||
// Do NOT grant notifications permission — toggle should start unchecked
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// Either aria-pressed="false" or disabled (when browser denies permissions)
|
||||
@@ -138,6 +153,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// Do not grant permission (leave as denied/prompt)
|
||||
await context.clearPermissions()
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// When permission is denied, the toggle should be disabled or aria-pressed="false"
|
||||
|
||||
@@ -4,6 +4,11 @@ import { E2E_PIN } from '../../e2e-constants'
|
||||
test.describe('Parent profile button – temporary parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Dismiss tutorial overlay if present — it intercepts pointer events
|
||||
const skip = page.getByRole('button', { name: 'Cancel' })
|
||||
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skip.click()
|
||||
}
|
||||
// Switch from permanent mode to temporary mode:
|
||||
// 1. Exit parent mode via Child Mode
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
|
||||
@@ -4,6 +4,11 @@ import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
|
||||
test.describe('Parent profile button – permanent parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Dismiss tutorial overlay if present — it intercepts pointer events
|
||||
const skip = page.getByRole('button', { name: 'Cancel' })
|
||||
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skip.click()
|
||||
}
|
||||
})
|
||||
|
||||
test('Badge – shows 🔒 in permanent parent mode', async ({ page }) => {
|
||||
@@ -23,7 +28,7 @@ test.describe('Parent profile button – permanent parent mode', () => {
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode exits parent mode', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const CHILD_NAME = 'AssignFabChild'
|
||||
const CHORE_NAME = 'AssignFabChore'
|
||||
const KINDNESS_NAME = 'AssignFabKindness'
|
||||
const PENALTY_NAME = 'AssignFabPenalty'
|
||||
const REWARD_NAME = 'AssignFabReward'
|
||||
const ROUTINE_NAME = 'AssignFabRoutine'
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (
|
||||
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (
|
||||
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function createReward(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/reward/list')
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
||||
}
|
||||
await request.put('/api/reward/add', { data: { name, description: 'E2E fab reward', cost: 10 } })
|
||||
const list = await request.get('/api/reward/list')
|
||||
return (
|
||||
(await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
async function createRoutine(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/routine/list')
|
||||
for (const r of (await pre.json()).routines ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
|
||||
}
|
||||
const res = await request.put('/api/routine/add', { data: { name, points: 5 } })
|
||||
return (await res.json()).routine?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Assignment views create FAB', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindnessId = ''
|
||||
let penaltyId = ''
|
||||
let rewardId = ''
|
||||
let routineId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore')
|
||||
kindnessId = await createTask(request, KINDNESS_NAME, 'kindness')
|
||||
penaltyId = await createTask(request, PENALTY_NAME, 'penalty')
|
||||
rewardId = await createReward(request, REWARD_NAME)
|
||||
routineId = await createRoutine(request, ROUTINE_NAME)
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`/api/child/${childId}`)
|
||||
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||
if (kindnessId) await request.delete(`/api/task/${kindnessId}`)
|
||||
if (penaltyId) await request.delete(`/api/task/${penaltyId}`)
|
||||
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
|
||||
if (routineId) await request.delete(`/api/routine/${routineId}`)
|
||||
})
|
||||
|
||||
test('Chore assign view FAB navigates to chore creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/chores\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Chore' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Kindness assign view FAB navigates to kindness creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/kindness\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Kindness Act' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Penalty assign view FAB navigates to penalty creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Penalty' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/penalties\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Penalty' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reward assign view FAB navigates to reward creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Reward' }).click()
|
||||
await page.waitForURL(/\/parent\/rewards\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Reward' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Routine assign view FAB navigates to routine creator', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}/assign-routines?name=${CHILD_NAME}`)
|
||||
await expect(page.getByRole('heading', { name: 'Assign Routines' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Routine' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/routines\/create$/)
|
||||
await expect(page.getByRole('heading', { name: 'Create Routine' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
const CHILD_NAME = 'DialogHelpChild'
|
||||
const CHORE_NAME = 'DialogHelpChore'
|
||||
const KINDNESS_NAME = 'DialogHelpKindness'
|
||||
const PENALTY_NAME = 'DialogHelpPenalty'
|
||||
const REWARD_NAME = 'DialogHelpReward'
|
||||
const REWARD_COST = 10
|
||||
const ROUTINE_NAME = 'DialogHelpRoutine'
|
||||
|
||||
async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { enabled },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTutorialProgress(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { reset: true },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/child/list`)
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`${BACKEND}/child/${c.id}`)
|
||||
}
|
||||
await request.put(`${BACKEND}/child/add`, { data: { name, age: 8 } })
|
||||
const list = await request.get(`${BACKEND}/child/list`)
|
||||
return (
|
||||
(await list.json()).children?.find(
|
||||
(c: { name: string; id: string }) => c.name === name,
|
||||
)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/task/list`)
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`${BACKEND}/task/${t.id}`)
|
||||
}
|
||||
await request.put(`${BACKEND}/task/add`, { data: { name, points: 5, type } })
|
||||
const list = await request.get(`${BACKEND}/task/list`)
|
||||
return (
|
||||
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
async function createReward(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/reward/list`)
|
||||
for (const r of (await pre.json()).rewards ?? []) {
|
||||
if (r.name === name) await request.delete(`${BACKEND}/reward/${r.id}`)
|
||||
}
|
||||
await request.put(`${BACKEND}/reward/add`, {
|
||||
data: { name, description: 'E2E dialog reward', cost: REWARD_COST },
|
||||
})
|
||||
const list = await request.get(`${BACKEND}/reward/list`)
|
||||
return (
|
||||
(await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
async function createRoutine(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get(`${BACKEND}/routine/list`)
|
||||
for (const r of (await pre.json()).routines ?? []) {
|
||||
if (r.name === name) await request.delete(`${BACKEND}/routine/${r.id}`)
|
||||
}
|
||||
const res = await request.put(`${BACKEND}/routine/add`, { data: { name, points: 5 } })
|
||||
return (await res.json()).routine?.id ?? ''
|
||||
}
|
||||
|
||||
async function assignTask(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
type: 'chore' | 'kindness' | 'penalty',
|
||||
): Promise<void> {
|
||||
const res = await request.put(`${BACKEND}/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [taskId], type },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to assign ${type}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assignReward(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
rewardId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.put(`${BACKEND}/child/${childId}/set-rewards`, {
|
||||
data: { reward_ids: [rewardId] },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to assign reward: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assignRoutine(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
routineId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, {
|
||||
data: { routine_id: routineId },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to assign routine: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
function helpButton(page: Page) {
|
||||
return page.getByRole('button', { name: 'Show help for this screen' })
|
||||
}
|
||||
|
||||
function modalBackdrop(page: Page) {
|
||||
return page.locator('.modal-backdrop')
|
||||
}
|
||||
|
||||
async function dismissAutoTutorialChain(page: Page): Promise<void> {
|
||||
const card = page.locator('.tutorial-root .card')
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!(await card.isVisible().catch(() => false))) return
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
}
|
||||
|
||||
function sectionByHeading(page: Page, heading: string) {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: heading }),
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Dialog help button and titles', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let childId = ''
|
||||
let choreId = ''
|
||||
let kindnessId = ''
|
||||
let penaltyId = ''
|
||||
let rewardId = ''
|
||||
let routineId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await setTutorialEnabled(request, true)
|
||||
await resetTutorialProgress(request)
|
||||
|
||||
childId = await createChild(request, CHILD_NAME)
|
||||
choreId = await createTask(request, CHORE_NAME, 'chore')
|
||||
kindnessId = await createTask(request, KINDNESS_NAME, 'kindness')
|
||||
penaltyId = await createTask(request, PENALTY_NAME, 'penalty')
|
||||
rewardId = await createReward(request, REWARD_NAME)
|
||||
routineId = await createRoutine(request, ROUTINE_NAME)
|
||||
|
||||
await assignTask(request, childId, choreId, 'chore')
|
||||
await assignTask(request, childId, kindnessId, 'kindness')
|
||||
await assignTask(request, childId, penaltyId, 'penalty')
|
||||
await assignReward(request, childId, rewardId)
|
||||
await assignRoutine(request, childId, routineId)
|
||||
|
||||
// Give the child enough points so the reward is ready to redeem.
|
||||
await request.put(`${BACKEND}/child/${childId}/edit`, { data: { points: REWARD_COST } })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (childId) await request.delete(`${BACKEND}/child/${childId}`)
|
||||
if (choreId) await request.delete(`${BACKEND}/task/${choreId}`)
|
||||
if (kindnessId) await request.delete(`${BACKEND}/task/${kindnessId}`)
|
||||
if (penaltyId) await request.delete(`${BACKEND}/task/${penaltyId}`)
|
||||
if (rewardId) await request.delete(`${BACKEND}/reward/${rewardId}`)
|
||||
if (routineId) await request.delete(`${BACKEND}/routine/${routineId}`)
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await dismissAutoTutorialChain(page)
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Task confirm dialog hides help button and shows "Confirm Task" for chores', async ({
|
||||
page,
|
||||
}) => {
|
||||
const card = sectionByHeading(page, 'Chores').locator('.item-card').filter({ hasText: CHORE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Task')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Task confirm dialog hides help button and shows "Confirm Act" for kindness acts', async ({
|
||||
page,
|
||||
}) => {
|
||||
const card = sectionByHeading(page, 'Kindness Acts')
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: KINDNESS_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Act')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Task confirm dialog hides help button for penalties', async ({ page }) => {
|
||||
const card = sectionByHeading(page, 'Penalties')
|
||||
.locator('.item-card')
|
||||
.filter({ hasText: PENALTY_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Penalty')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Reward confirm dialog hides help button and shows "Grant Reward"', async ({ page }) => {
|
||||
const card = sectionByHeading(page, 'Rewards').locator('.item-card').filter({ hasText: REWARD_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await expect(card.getByText('REWARD READY')).toBeVisible()
|
||||
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Grant Reward')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Routine confirm dialog hides help button and shows "Confirm Routine"', async ({ page }) => {
|
||||
const card = sectionByHeading(page, 'Routines').locator('.item-card').filter({ hasText: ROUTINE_NAME })
|
||||
await card.waitFor({ state: 'visible' })
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.click()
|
||||
|
||||
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('.modal-title')).toHaveText('Confirm Routine')
|
||||
await expect(helpButton(page)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('help button replays the full tutorial chain when tutorial tips are disabled', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('.tutorial-root .card')).not.toBeVisible()
|
||||
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await helpButton(page).click()
|
||||
|
||||
const cardTitle = page.locator('.tutorial-root .card .title')
|
||||
await expect(cardTitle).toHaveText("This is your child's page")
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(cardTitle).toHaveText('Assign chores')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(cardTitle).toHaveText('Assign kindness acts')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(cardTitle).toHaveText('Assign rewards')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(cardTitle).toHaveText('Assign routines')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(cardTitle).toHaveText('Assign penalties')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(cardTitle).toHaveText('Quick actions')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(page.locator('.tutorial-root .card')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,444 @@
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { enabled },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTutorialProgress(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { reset: true },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllChildren(request: APIRequestContext): Promise<void> {
|
||||
const listRes = await request.get(`${BACKEND}/child/list`)
|
||||
const data = await listRes.json()
|
||||
for (const child of data.children ?? []) {
|
||||
await request.delete(`${BACKEND}/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string, age: number): Promise<string> {
|
||||
const res = await request.put(`${BACKEND}/child/add`, {
|
||||
data: { name, age, image_id: 'boy01' },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create child ${name}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
// /child/add returns a message but not the created child, so read the list.
|
||||
const id = await getFirstChildId(request)
|
||||
if (!id) {
|
||||
throw new Error(`Child ${name} was not found after creation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function getFirstChildId(request: APIRequestContext): Promise<string | null> {
|
||||
const res = await request.get(`${BACKEND}/child/list`)
|
||||
const data = await res.json()
|
||||
return data.children?.[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function ensureChild(request: APIRequestContext, name: string, age: number): Promise<string> {
|
||||
const existingId = await getFirstChildId(request)
|
||||
if (existingId) return existingId
|
||||
return createChild(request, name, age)
|
||||
}
|
||||
|
||||
async function getFirstChoreId(request: APIRequestContext): Promise<string | null> {
|
||||
const res = await request.get(`${BACKEND}/chore/list`)
|
||||
const data = await res.json()
|
||||
return data.tasks?.[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function createChore(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const res = await request.put(`${BACKEND}/chore/add`, {
|
||||
data: { name, points, image_id: 'boy01' },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create chore ${name}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
const id = await getFirstChoreId(request)
|
||||
if (!id) {
|
||||
throw new Error(`Chore ${name} was not found after creation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function assignChoreToChild(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
choreId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${BACKEND}/child/${childId}/assign-task`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to assign chore ${choreId} to child ${childId}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoutine(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const res = await request.put(`${BACKEND}/routine/add`, {
|
||||
data: { name, points, image_id: 'boy01' },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create routine ${name}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const id = data.routine?.id
|
||||
if (!id) {
|
||||
throw new Error(`Routine ${name} was not found after creation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function assignRoutineToChild(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
routineId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, {
|
||||
data: { routine_id: routineId },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to assign routine ${routineId} to child ${childId}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getTutorialCard(page: Page) {
|
||||
return page.locator('.tutorial-root .card')
|
||||
}
|
||||
|
||||
function getTutorialTitle(page: Page) {
|
||||
return page.locator('.tutorial-root .card .title')
|
||||
}
|
||||
|
||||
async function expectTutorialCard(page: Page, title: string): Promise<void> {
|
||||
await expect(getTutorialCard(page)).toBeVisible({ timeout: 10000 })
|
||||
await expect(getTutorialTitle(page)).toHaveText(title)
|
||||
}
|
||||
|
||||
async function dismissTutorial(page: Page): Promise<void> {
|
||||
const card = getTutorialCard(page)
|
||||
if (await card.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
// Use "Cancel" to clear the active step and drain the queue in one
|
||||
// action, avoiding chained steps that would keep the card visible.
|
||||
await page.locator('.tutorial-root .btn-skip').click()
|
||||
await expect(card).not.toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function clickTutorialNext(page: Page): Promise<void> {
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a tutorial by clicking the primary button repeatedly until the card
|
||||
* disappears. This walks through any chained steps without setting sessionSkipped.
|
||||
*/
|
||||
async function dismissTutorialChain(page: Page, maxClicks = 10): Promise<void> {
|
||||
const card = getTutorialCard(page)
|
||||
for (let i = 0; i < maxClicks; i++) {
|
||||
if (!(await card.isVisible().catch(() => false))) return
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
await expect(card).not.toBeVisible({ timeout: 3000 })
|
||||
}
|
||||
|
||||
test.describe('Tutorial system', () => {
|
||||
// Tutorial state is global to the signed-in user; run these tests sequentially
|
||||
// so parallel resets do not interfere with each other.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllChildren(request)
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
await setTutorialEnabled(request, true)
|
||||
await resetTutorialProgress(request)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
await dismissTutorial(page)
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
})
|
||||
|
||||
test('children list shows create-child tutorial when no children exist', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Retry deletion + navigation so a concurrent test that creates a child
|
||||
// does not leave us on the child-points tutorial instead of create-child.
|
||||
await expect(async () => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/parent')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expectTutorialCard(page, 'Add your child')
|
||||
}).toPass({ timeout: 20000 })
|
||||
})
|
||||
|
||||
test('help button shows parent-children-list chain on the children list', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialKid'
|
||||
await ensureChild(request, childName, 7)
|
||||
await page.goto('/parent')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// Wait for the child card to render ( tolerate duplicate names from repeat runs ).
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// The children list may auto-show a brief loading-state hint. Clear any
|
||||
// active chain so the help button is reachable.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Show help for this screen' }).click()
|
||||
|
||||
await expectTutorialCard(page, 'Your children')
|
||||
await clickTutorialNext(page)
|
||||
|
||||
await expectTutorialCard(page, 'Points')
|
||||
await clickTutorialNext(page)
|
||||
|
||||
await expectTutorialCard(page, 'Tap a child')
|
||||
})
|
||||
|
||||
test('create chore form shows edit-chore-name tutorial', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores/create')
|
||||
await expect(page).toHaveURL('/parent/tasks/chores/create')
|
||||
await expectTutorialCard(page, 'Chore Name')
|
||||
})
|
||||
|
||||
test('help button shows list-chore-help on the chore list', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await expect(page).toHaveURL('/parent/tasks/chores')
|
||||
|
||||
// No auto-shown tutorial should appear on this page.
|
||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
|
||||
|
||||
const helpButton = page.getByRole('button', { name: 'Show help for this screen' })
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
await expectTutorialCard(page, 'Create your chore')
|
||||
})
|
||||
|
||||
test('help button chains through list-edit-hint on the chore list', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await expect(page).toHaveURL('/parent/tasks/chores')
|
||||
|
||||
await page.getByRole('button', { name: 'Show help for this screen' }).click()
|
||||
await expectTutorialCard(page, 'Create your chore')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit items')
|
||||
})
|
||||
|
||||
test('help button shows select-child on the child detail page', async ({ page, request }) => {
|
||||
const childName = 'TutorialKid'
|
||||
const childId = await ensureChild(request, childName, 7)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page).toHaveURL(`/parent/${childId}`)
|
||||
|
||||
// Wait for the child data to load and the assign buttons to render.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// The page auto-shows select-child on first visit. Walk through the chain
|
||||
// so the help button is reachable and sessionSkipped stays false.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Show help for this screen' }).click()
|
||||
await expectTutorialCard(page, "This is your child's page")
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Assign chores')
|
||||
})
|
||||
|
||||
test('kebab menu on assigned chore shows chore-kebab-menu tutorial', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialKid'
|
||||
const childId = await ensureChild(request, childName, 7)
|
||||
const choreId = await createChore(request, 'TutorialChore', 10)
|
||||
await assignChoreToChild(request, childId, choreId)
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page).toHaveURL(`/parent/${childId}`)
|
||||
|
||||
// Wait for the child data and the assigned chore to render.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText('TutorialChore').first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Clear the auto-shown select-child chain.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
// Click the chore card to make it ready (reveals the kebab button).
|
||||
await page.locator('.item-card').filter({ hasText: 'TutorialChore' }).first().click()
|
||||
|
||||
// Click the kebab button and verify the chore kebab tutorial fires.
|
||||
const kebabButton = page
|
||||
.locator('.kebab-btn')
|
||||
.filter({ has: page.locator('text=⋮') })
|
||||
.first()
|
||||
await expect(kebabButton).toBeVisible({ timeout: 5000 })
|
||||
await kebabButton.click()
|
||||
|
||||
await expectTutorialCard(page, 'Chore actions')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit points')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Change schedule')
|
||||
})
|
||||
|
||||
test('kebab menu on assigned routine shows routine-kebab-menu tutorial', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialKid'
|
||||
const childId = await ensureChild(request, childName, 7)
|
||||
const routineId = await createRoutine(request, 'TutorialRoutine', 15)
|
||||
await assignRoutineToChild(request, childId, routineId)
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page).toHaveURL(`/parent/${childId}`)
|
||||
|
||||
// Wait for the child data and the assigned routine to render.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText('TutorialRoutine').first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Clear the auto-shown select-child chain.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
// Click the routine card to make it ready (reveals the kebab button).
|
||||
await page.locator('.item-card').filter({ hasText: 'TutorialRoutine' }).first().click()
|
||||
|
||||
// Click the kebab button and verify the routine kebab tutorial fires.
|
||||
const kebabButton = page
|
||||
.locator('.kebab-btn')
|
||||
.filter({ has: page.locator('text=⋮') })
|
||||
.first()
|
||||
await expect(kebabButton).toBeVisible({ timeout: 5000 })
|
||||
await kebabButton.click()
|
||||
|
||||
await expectTutorialCard(page, 'Routine actions')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit routine')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit points')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Change schedule')
|
||||
})
|
||||
|
||||
test('dismissing an auto-shown tutorial persists across reloads', async ({ page, request }) => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/parent')
|
||||
await expectTutorialCard(page, 'Add your child')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await page.reload()
|
||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('add-child FAB is disabled while the create-child tutorial is showing', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/parent')
|
||||
await expectTutorialCard(page, 'Add your child')
|
||||
|
||||
const fab = page.locator('.fab')
|
||||
await expect(fab).toBeVisible()
|
||||
await expect(fab).toBeDisabled()
|
||||
|
||||
// The page should still be on the children list and the tutorial visible.
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(getTutorialCard(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test('child form inputs are disabled while the edit-child-name tutorial is showing', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/children/create')
|
||||
await expect(page).toHaveURL('/parent/children/create')
|
||||
await expectTutorialCard(page, "Child's Name")
|
||||
|
||||
await expect(page.locator('input#name')).toBeDisabled()
|
||||
await expect(page.locator('input#age')).toBeDisabled()
|
||||
|
||||
// The tutorial card should remain visible after checking the inputs.
|
||||
await expect(getTutorialCard(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking the highlighted Points area does not navigate while child-points tutorial is showing', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialPointsKid'
|
||||
await ensureChild(request, childName, 7)
|
||||
await page.goto('/parent')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// Wait for the child card to render so the children list has finished
|
||||
// loading before we assert on the Points tutorial.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await expectTutorialCard(page, 'Points')
|
||||
|
||||
// Small resilience delay so the spotlight blocker is positioned before the
|
||||
// synthetic click reaches it.
|
||||
await page.waitForTimeout(50)
|
||||
|
||||
const points = page.locator('.card .points').first()
|
||||
await expect(points).toBeVisible()
|
||||
const box = await points.boundingBox()
|
||||
if (!box) throw new Error('Could not resolve points element bounding box')
|
||||
|
||||
// Click the center of the highlighted points area. The tutorial spotlight
|
||||
// blocker should intercept the click, preventing the card click handler
|
||||
// from navigating to the child detail page.
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
|
||||
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expect(getTutorialCard(page)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,17 @@ import { E2E_PIN } from '../../e2e-constants'
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
const NEW_PIN = '5678'
|
||||
|
||||
async function openAccountSection(page: import('@playwright/test').Page): Promise<void> {
|
||||
const accountHeader = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Account' }) })
|
||||
.locator('.section-header')
|
||||
if ((await accountHeader.getAttribute('aria-expanded')) === 'false') {
|
||||
await accountHeader.click()
|
||||
}
|
||||
await page.locator('#section-account').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> {
|
||||
// Request a new code, retrieve it via test endpoint, verify it, then set the pin
|
||||
await request.post(`${BACKEND}/user/request-pin-setup`)
|
||||
@@ -25,6 +36,7 @@ test.describe('User Profile – Change Parent PIN', () => {
|
||||
|
||||
test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
|
||||
@@ -35,6 +47,7 @@ test.describe('User Profile – Change Parent PIN', () => {
|
||||
test('Back from PIN setup page returns to profile', async ({ page }) => {
|
||||
// Navigate from the profile page so browser history exists
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
|
||||
|
||||
@@ -3,11 +3,23 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
async function openAccountSection(page: import('@playwright/test').Page): Promise<void> {
|
||||
const accountHeader = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Account' }) })
|
||||
.locator('.section-header')
|
||||
if ((await accountHeader.getAttribute('aria-expanded')) === 'false') {
|
||||
await accountHeader.click()
|
||||
}
|
||||
await page.locator('#section-account').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
test.describe('User Profile – Delete Account', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
|
||||
@@ -24,6 +36,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
|
||||
test('Delete button stays disabled for incomplete email', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -36,6 +49,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
|
||||
test('Delete button enables when matching email is entered', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -48,6 +62,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
|
||||
test('Cancel closes the dialog without deleting the account', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -57,12 +72,13 @@ test.describe('User Profile – Delete Account', () => {
|
||||
await expect(
|
||||
page.locator('.modal-title', { hasText: 'Delete Your Account?' }),
|
||||
).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Backdrop click does NOT close the delete dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -77,6 +93,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
|
||||
@@ -32,11 +32,27 @@ async function restoreProfile(request: APIRequestContext, profile: ProfileData):
|
||||
})
|
||||
}
|
||||
|
||||
/** Expand a collapsible profile section by its header title and wait for its content. */
|
||||
async function expandSection(page: import('@playwright/test').Page, title: string): Promise<void> {
|
||||
const header = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: title }) })
|
||||
.locator('.section-header')
|
||||
const expanded = await header.getAttribute('aria-expanded').catch(() => 'false')
|
||||
if (expanded === 'false') {
|
||||
await header.click()
|
||||
}
|
||||
await page.locator(`#section-${title.toLowerCase()}`).waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Navigate to /parent/profile and wait for the form to finish loading. */
|
||||
async function gotoProfile(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/parent/profile')
|
||||
// EntityEditForm hides the form behind v-if while loading=true; wait for it to render.
|
||||
await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 })
|
||||
// Expand sections that are collapsed by default so their fields/buttons are reachable.
|
||||
await expandSection(page, 'Account')
|
||||
await expandSection(page, 'Notifications')
|
||||
}
|
||||
|
||||
test.describe('User Profile – editing', () => {
|
||||
@@ -55,7 +71,7 @@ test.describe('User Profile – editing', () => {
|
||||
test('Profile page loads with correct data', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('Tester')
|
||||
await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL)
|
||||
@@ -69,82 +85,33 @@ test.describe('User Profile – editing', () => {
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
await expect(page).toHaveURL('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
// The profile view shows a header Back button (auto-save form has no Cancel).
|
||||
await page.getByRole('button', { name: 'Back' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/parent')
|
||||
})
|
||||
|
||||
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('First Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save enables when a name is changed', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||
test('Name changes auto-save on blur', async ({ page, request }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
await page.getByLabel('Last Name').fill('UpdatedTester')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog.locator('.modal-title', { hasText: 'Profile Updated' })).toBeVisible()
|
||||
await expect(dialog.getByText('Your profile was updated successfully.')).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
// Wait for the auto-save PUT to complete and verify persistence via API.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const profile = await getProfile(request)
|
||||
return profile.first_name === 'UpdatedE2E' && profile.last_name === 'UpdatedTester'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
// OK navigates back; go back to profile to verify persistence
|
||||
// Reloading the profile page shows the persisted values.
|
||||
await gotoProfile(page)
|
||||
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
||||
})
|
||||
|
||||
test('Cancel discards unsaved changes', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('Discarded')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Navigate back to verify no changes were saved
|
||||
await gotoProfile(page)
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
})
|
||||
|
||||
test('Email field is read-only', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
@@ -153,7 +120,7 @@ test.describe('User Profile – editing', () => {
|
||||
await expect(emailInput).toHaveValue(E2E_EMAIL)
|
||||
})
|
||||
|
||||
test('Change profile image (built-in)', async ({ page }) => {
|
||||
test('Change profile image (built-in)', async ({ page, request }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
// Wait for images to load
|
||||
@@ -180,24 +147,22 @@ test.describe('User Profile – editing', () => {
|
||||
// Confirm it is now selected
|
||||
await expect(images.nth(targetIndex)).toHaveClass(/selected/)
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
// Images auto-save on selection; verify via API.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const profile = await getProfile(request)
|
||||
return Boolean(profile.image_id)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
// Re-visit and confirm selection persists
|
||||
await gotoProfile(page)
|
||||
await page.waitForSelector('.selectable-image')
|
||||
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
||||
expect(selectedSrc).toBeTruthy()
|
||||
// The URL will differ after a new load (Object URL vs cached), so verify via API
|
||||
const profile = await getProfile(page.request)
|
||||
expect(profile.image_id).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Upload a custom profile image', async ({ page }) => {
|
||||
test('Upload a custom profile image', async ({ page, request }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.waitForSelector('.selectable-image')
|
||||
@@ -210,14 +175,13 @@ test.describe('User Profile – editing', () => {
|
||||
// The uploaded image appears first in the list and is selected
|
||||
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
// Images auto-save on upload; verify via API.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const profile = await getProfile(request)
|
||||
return Boolean(profile.image_id)
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Change Password shows email-sent modal', async ({ page }) => {
|
||||
@@ -243,6 +207,6 @@ test.describe('User Profile – editing', () => {
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Modal dismissed, back on the profile page
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
STORAGE_STATE_NO_PIN,
|
||||
STORAGE_STATE_DELETE,
|
||||
STORAGE_STATE_CC,
|
||||
STORAGE_STATE_TUTORIAL,
|
||||
} from './e2e/e2e-constants'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
* E2E MongoDB configuration is hardcoded below. Developers can override
|
||||
* values via environment variables; see .env.test for an example.
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
@@ -48,6 +46,12 @@ export default defineConfig({
|
||||
// Depends on 'setup' because e2e-seed (run in setup) truncates all users first.
|
||||
{ name: 'setup-delete', testMatch: /auth-delete\.setup\.ts/, dependencies: ['setup'] },
|
||||
{ name: 'setup-cc', testMatch: /auth-cc\.setup\.ts/, dependencies: ['setup'] },
|
||||
{
|
||||
name: 'setup-tutorial',
|
||||
testMatch: /auth-tutorial\.setup\.ts/,
|
||||
// Depends on setup because e2e-seed truncates all users first.
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket A: child-options tests — run before create-child so that
|
||||
@@ -170,6 +174,25 @@ export default defineConfig({
|
||||
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: tutorial system tests — uses an isolated user so tutorial
|
||||
// enabled/progress state never interferes with other buckets.
|
||||
name: 'chromium-tutorial',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
|
||||
dependencies: ['setup-tutorial'],
|
||||
testMatch: [/mode_parent\/tutorial\/.+\.spec\.ts/],
|
||||
testIgnore: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: dialog help-button/title tests — depends on the tutorial bucket
|
||||
// so it reuses the isolated tutorial user without running concurrently.
|
||||
name: 'chromium-dialog',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
|
||||
dependencies: ['chromium-tutorial'],
|
||||
testMatch: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium-tasks-rewards',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||
@@ -186,6 +209,7 @@ export default defineConfig({
|
||||
/mode_parent\/chore-scheduler\//,
|
||||
/mode_parent\/notifications\//,
|
||||
/mode_parent\/routines\//,
|
||||
/mode_parent\/tutorial\//,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -261,6 +285,8 @@ export default defineConfig({
|
||||
'BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE',
|
||||
VAPID_PRIVATE_KEY: 'jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ',
|
||||
PROCESS_PLATFORM: process.platform,
|
||||
USE_MONGODB: process.env.USE_MONGODB || 'true',
|
||||
MONGO_URI: process.env.MONGO_URI || 'mongomock',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<BackendEventsListener />
|
||||
<router-view />
|
||||
<TutorialOverlay />
|
||||
<TutorialChildModeOffer />
|
||||
<HelpButton />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BackendEventsListener from '@/components/BackendEventsListener.vue'
|
||||
import TutorialOverlay from '@/tutorial/TutorialOverlay.vue'
|
||||
import TutorialChildModeOffer from '@/tutorial/TutorialChildModeOffer.vue'
|
||||
import HelpButton from '@/tutorial/HelpButton.vue'
|
||||
import { checkAuth } from '@/stores/auth'
|
||||
|
||||
checkAuth()
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import App from '../App.vue'
|
||||
|
||||
const mockRouter = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', name: 'Home', component: { template: '<div />' } }],
|
||||
})
|
||||
|
||||
describe('App', () => {
|
||||
it('mounts renders properly', () => {
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
'router-view': {
|
||||
template: '<div>You did it!</div>',
|
||||
|
||||
@@ -146,6 +146,13 @@ describe('ScheduleModal Specific Days form', () => {
|
||||
expect(w.find('.default-deadline-row').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the enable-toggle row for the tutorial anchor', () => {
|
||||
const w = mountModal()
|
||||
const toggleRow = w.find('.schedule-toggle-row')
|
||||
expect(toggleRow.exists()).toBe(true)
|
||||
expect(toggleRow.attributes('data-tutorial')).toBe('schedule-enable-toggle')
|
||||
})
|
||||
|
||||
it('Save is disabled when no days selected (isDirty is false)', () => {
|
||||
const w = mountModal()
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
|
||||
@@ -34,6 +34,43 @@ vi.mock('../services/pushSubscription', () => ({
|
||||
getPushPermissionState: vi.fn().mockReturnValue('default'),
|
||||
}))
|
||||
|
||||
const mockResetAllProgress = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/tutorial/controller', () => ({
|
||||
tutorialEnabled: { value: true },
|
||||
setTutorialEnabled: vi.fn(),
|
||||
resetAllProgress: () => mockResetAllProgress(),
|
||||
}))
|
||||
|
||||
function stubModalDialog() {
|
||||
return {
|
||||
template: '<div class="mock-modal"><h2 v-if="title">{{ title }}</h2><slot /></div>',
|
||||
props: ['title'],
|
||||
}
|
||||
}
|
||||
|
||||
function stubImagePicker() {
|
||||
return {
|
||||
template: '<div class="mock-image-picker" />',
|
||||
props: ['modelValue', 'imageType'],
|
||||
emits: ['update:modelValue', 'add-image'],
|
||||
}
|
||||
}
|
||||
|
||||
function stubToggleField() {
|
||||
return {
|
||||
template: '<div class="mock-toggle-field" />',
|
||||
props: ['label', 'modelValue', 'disabled', 'description', 'error'],
|
||||
emits: ['update:modelValue'],
|
||||
}
|
||||
}
|
||||
|
||||
function stubProfileSection() {
|
||||
return {
|
||||
template: '<div class="mock-profile-section"><slot /></div>',
|
||||
props: ['title', 'defaultOpen'],
|
||||
}
|
||||
}
|
||||
|
||||
describe('UserProfile - Delete Account', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
@@ -57,31 +94,24 @@ describe('UserProfile - Delete Account', () => {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
EntityEditForm: {
|
||||
template:
|
||||
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
|
||||
},
|
||||
ModalDialog: {
|
||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders Delete My Account button', async () => {
|
||||
// Wait for component to mount and render
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Test the functionality exists by calling the method directly
|
||||
expect(wrapper.vm.openDeleteWarning).toBeDefined()
|
||||
expect(wrapper.vm.confirmDeleteAccount).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens warning modal when Delete My Account button is clicked', async () => {
|
||||
// Test by calling the method directly
|
||||
wrapper.vm.openDeleteWarning()
|
||||
await nextTick()
|
||||
|
||||
@@ -91,21 +121,19 @@ describe('UserProfile - Delete Account', () => {
|
||||
|
||||
it('Delete button in warning modal is disabled until email matches', async () => {
|
||||
// Set initial email
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
|
||||
// Open warning modal
|
||||
await wrapper.vm.openDeleteWarning()
|
||||
await nextTick()
|
||||
|
||||
// Find modal delete button (we need to check :disabled binding)
|
||||
// Since we're using a stub, we'll test the logic directly
|
||||
wrapper.vm.confirmEmail = 'wrong@example.com'
|
||||
await nextTick()
|
||||
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.initialData.email)
|
||||
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.email)
|
||||
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
await nextTick()
|
||||
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.initialData.email)
|
||||
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.email)
|
||||
})
|
||||
|
||||
it('calls API when confirmed with correct email', async () => {
|
||||
@@ -115,7 +143,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -132,7 +160,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
})
|
||||
|
||||
it('does not call API if email is invalid format', async () => {
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'invalid-email'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -149,7 +177,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -166,7 +194,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
return { ok: true, json: async () => ({ success: true }) }
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -180,7 +208,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -196,7 +224,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
json: async () => ({ error: 'fail', code: 'ERROR' }),
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -208,7 +236,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
it('clears suppressForceLogout on network error', async () => {
|
||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -228,7 +256,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -242,7 +270,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
it('shows error modal on network error', async () => {
|
||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -307,7 +335,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
const deletePromise = wrapper.vm.confirmDeleteAccount()
|
||||
@@ -344,7 +372,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -354,7 +382,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserProfile - Profile Update', () => {
|
||||
describe('UserProfile - Auto-save', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -369,90 +397,55 @@ describe('UserProfile - Profile Update', () => {
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: true,
|
||||
}),
|
||||
})
|
||||
|
||||
// Mount component with router
|
||||
wrapper = mount(UserProfile, {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
EntityEditForm: {
|
||||
template: '<div class="mock-form"><slot /></div>',
|
||||
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
|
||||
emits: ['submit', 'cancel', 'add-image'],
|
||||
},
|
||||
ModalDialog: {
|
||||
template: '<div class="mock-modal"><slot /></div>',
|
||||
},
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('updates initialData after successful profile save', async () => {
|
||||
it('saveNames sends PUT with first and last name', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
// Initial image_id should be set from mount
|
||||
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
||||
|
||||
// Mock successful save response
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
// Simulate form submission with new image_id
|
||||
const newFormData = {
|
||||
image_id: 'new-image-id',
|
||||
first_name: 'Updated',
|
||||
last_name: 'Name',
|
||||
email: 'test@example.com',
|
||||
}
|
||||
|
||||
await wrapper.vm.handleSubmit(newFormData)
|
||||
wrapper.vm.firstName = 'Updated'
|
||||
wrapper.vm.lastName = 'Name'
|
||||
await wrapper.vm.saveNames()
|
||||
await flushPromises()
|
||||
|
||||
// initialData should now be updated to match the saved form
|
||||
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
||||
expect(wrapper.vm.initialData.first_name).toBe('Updated')
|
||||
expect(wrapper.vm.initialData.last_name).toBe('Name')
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.first_name).toBe('Updated')
|
||||
expect(body.last_name).toBe('Name')
|
||||
})
|
||||
|
||||
it('allows dirty detection after save when reverting to original value', async () => {
|
||||
it('saveImage sends PUT with image_id', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
// Start with initial-image-id
|
||||
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
||||
|
||||
// Mock successful save
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
// Change and save to new-image-id
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'new-image-id',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.saveImage('new-image-id')
|
||||
await flushPromises()
|
||||
|
||||
// initialData should now be new-image-id
|
||||
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
||||
|
||||
// Now if user changes back to initial-image-id, it should be detected as different
|
||||
// (because initialData is now new-image-id)
|
||||
const currentInitial = wrapper.vm.initialData.image_id
|
||||
expect(currentInitial).toBe('new-image-id')
|
||||
expect(currentInitial).not.toBe('initial-image-id')
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.image_id).toBe('new-image-id')
|
||||
})
|
||||
|
||||
it('handles image upload during profile save', async () => {
|
||||
it('uploadLocalImage uploads file then saves image_id', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
@@ -471,24 +464,18 @@ describe('UserProfile - Profile Update', () => {
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'local-upload',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.uploadLocalImage()
|
||||
await flushPromises()
|
||||
|
||||
// Should have called image upload
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/image/upload',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
const uploadCall = (global.fetch as any).mock.calls.find(
|
||||
(c: any[]) => c[0] === '/api/image/upload',
|
||||
)
|
||||
expect(uploadCall).toBeDefined()
|
||||
expect(uploadCall[1].method).toBe('POST')
|
||||
|
||||
// initialData should be updated with uploaded image ID
|
||||
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id')
|
||||
// imageId should be updated
|
||||
expect(wrapper.vm.imageId).toBe('uploaded-image-id')
|
||||
})
|
||||
|
||||
it('shows error message on failed image upload', async () => {
|
||||
@@ -504,37 +491,10 @@ describe('UserProfile - Profile Update', () => {
|
||||
status: 500,
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'local-upload',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.uploadLocalImage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('shows success modal after profile update', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'some-image-id',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.showModal).toBe(true)
|
||||
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
|
||||
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
|
||||
})
|
||||
|
||||
it('shows error message on failed profile update', async () => {
|
||||
@@ -545,16 +505,10 @@ describe('UserProfile - Profile Update', () => {
|
||||
status: 500,
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'some-image-id',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.saveNames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -576,14 +530,10 @@ describe('UserProfile - Notification Toggles', () => {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
EntityEditForm: {
|
||||
template:
|
||||
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
|
||||
},
|
||||
ModalDialog: {
|
||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -598,64 +548,37 @@ describe('UserProfile - Notification Toggles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
|
||||
it('initializes emailDigestEnabled to true when profile returns true', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
|
||||
it('initializes emailDigestEnabled to false when profile returns false', async () => {
|
||||
wrapper = mountWithDigest(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('initializes push_enabled to false in initialData when not subscribed', async () => {
|
||||
it('initializes pushEnabled to false when not subscribed', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.push_enabled).toBe(false)
|
||||
expect(wrapper.vm.pushEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('fields array includes email_digest_enabled as toggle type', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled')
|
||||
expect(digestField).toBeDefined()
|
||||
expect(digestField.type).toBe('toggle')
|
||||
})
|
||||
|
||||
it('fields array includes push_enabled as toggle type', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled')
|
||||
expect(pushField).toBeDefined()
|
||||
expect(pushField.type).toBe('toggle')
|
||||
})
|
||||
|
||||
it('profile PUT includes email_digest_enabled when changed on submit', async () => {
|
||||
it('onToggleDigest sends PUT with new value', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: false,
|
||||
push_enabled: false,
|
||||
})
|
||||
await wrapper.vm.onToggleDigest(false)
|
||||
await flushPromises()
|
||||
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
@@ -664,25 +587,80 @@ describe('UserProfile - Notification Toggles', () => {
|
||||
expect(body.email_digest_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
|
||||
it('onTogglePush sends PUT with new value and applies push change', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: true, // same as initial
|
||||
push_enabled: false,
|
||||
})
|
||||
expect(wrapper.vm.pushEnabled).toBe(false)
|
||||
await wrapper.vm.onTogglePush(true)
|
||||
await flushPromises()
|
||||
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.email_digest_enabled).toBeUndefined()
|
||||
expect(body.push_notifications_enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserProfile - Tutorial restart', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResetAllProgress.mockClear()
|
||||
;(global.fetch as any).mockClear()
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: true,
|
||||
}),
|
||||
})
|
||||
|
||||
wrapper = mount(UserProfile, {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('restart confirm modal uses "Tutorial Restart" title and explanatory wording', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.openRestartConfirm()
|
||||
await nextTick()
|
||||
|
||||
const modal = wrapper.find('.mock-modal')
|
||||
expect(modal.exists()).toBe(true)
|
||||
expect(modal.text()).toContain('Tutorial Restart')
|
||||
expect(modal.text()).toContain('Start the tour again')
|
||||
})
|
||||
|
||||
it('confirming restart resets tutorial progress and shows success modal', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.openRestartConfirm()
|
||||
await wrapper.vm.confirmRestartTutorial()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(mockResetAllProgress).toHaveBeenCalledTimes(1)
|
||||
|
||||
const modal = wrapper.find('.mock-modal')
|
||||
expect(modal.text()).toContain('Tutorial Restart')
|
||||
expect(modal.text()).toContain('Tutorial mode has been restarted.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,25 @@ vi.mock('@/stores/auth', () => ({
|
||||
logoutUser: () => mockLogoutUser(),
|
||||
}))
|
||||
|
||||
function makeLocalStorageStub() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key]
|
||||
},
|
||||
clear: () => {
|
||||
for (const k of Object.keys(store)) delete store[k]
|
||||
},
|
||||
_store: store,
|
||||
}
|
||||
}
|
||||
|
||||
const localStorageStub = makeLocalStorageStub()
|
||||
|
||||
describe('installUnauthorizedFetchInterceptor', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
@@ -13,10 +32,13 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
||||
vi.resetModules()
|
||||
mockLogoutUser.mockReset()
|
||||
globalThis.fetch = vi.fn()
|
||||
localStorageStub.clear()
|
||||
vi.stubGlobal('localStorage', localStorageStub)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('attempts refresh on 401, retries the original request on success', async () => {
|
||||
@@ -181,4 +203,67 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets lastRefreshAt in localStorage after a successful refresh', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 } as Response)
|
||||
.mockResolvedValueOnce({ status: 200 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/user/profile')
|
||||
|
||||
const lastRefresh = localStorageStub.getItem('lastRefreshAt')
|
||||
expect(lastRefresh).not.toBeNull()
|
||||
expect(Number(lastRefresh)).toBeLessThanOrEqual(Date.now())
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips refresh call when another tab recently refreshed', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
// Only original request and retry; refresh should be skipped due to cross-tab coordination
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||
.mockResolvedValueOnce({ status: 200, body: 'retried' } as unknown as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const {
|
||||
installUnauthorizedFetchInterceptor,
|
||||
setUnauthorizedRedirectHandlerForTests,
|
||||
resetInterceptorStateForTests,
|
||||
} = await import('../api')
|
||||
resetInterceptorStateForTests()
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
// Simulate another tab having refreshed 1 second ago, after resetInterceptorStateForTests
|
||||
localStorageStub.setItem('lastRefreshAt', String(Date.now() - 1000))
|
||||
|
||||
const result = await fetch('/api/user/profile')
|
||||
|
||||
// Should not call /api/auth/refresh; only original + retry
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
|
||||
'/api/user/profile',
|
||||
'/api/user/profile',
|
||||
])
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
expect((result as Response).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface User {
|
||||
role: string
|
||||
timezone: string | null
|
||||
email_digest_enabled: boolean
|
||||
tutorial_enabled?: boolean
|
||||
tutorial_progress?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface Child {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -55,6 +56,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-child-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
@@ -9,6 +9,7 @@ import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
|
||||
import ChildRoutineOverlay from './ChildRoutineOverlay.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { setHelpButtonHidden } from '@/tutorial/controller'
|
||||
//import '@/assets/view-shared.css'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
@@ -640,6 +641,8 @@ const hasPendingRewards = computed(() =>
|
||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||
)
|
||||
|
||||
watch(showRewardDialog, (newVal) => setHelpButtonHidden(newVal))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton aria-label="Create Chore" @click="goToCreate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +37,7 @@ import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import '@/assets/styles.css'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton aria-label="Create Kindness Act" @click="goToCreate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +37,7 @@ import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import '@/assets/styles.css'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
triggerRoutineAsParent,
|
||||
} from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
activeStep as tutorialActiveStep,
|
||||
modalTutorialStepId,
|
||||
setHelpButtonHidden,
|
||||
} from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
Task,
|
||||
@@ -108,6 +114,9 @@ const selectedChoreId = ref<string | null>(null)
|
||||
const menuPosition = ref({ top: 0, left: 0 })
|
||||
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
// Tutorial auto-demo state
|
||||
const tutorialHighlightedItemId = ref<string | null>(null)
|
||||
|
||||
// Schedule modal
|
||||
const showScheduleModal = ref(false)
|
||||
const scheduleTarget = ref<ChildTask | null>(null)
|
||||
@@ -426,10 +435,45 @@ function openRoutineMenu(routineId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = routineKebabBtnRefs.value.get(routineId)
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeRoutineMenuFor.value = routineId
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-kebab-menu',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'kebab-edit-points-cost',
|
||||
() => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'routine-schedule',
|
||||
() => document.querySelector('[data-tutorial="routine-schedule"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
const items = childRoutineListRef.value?.items ?? []
|
||||
const routine = items.find((r) => r.id === routineId)
|
||||
if (routine) {
|
||||
if (isRoutineExpired(routine)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-extend-time',
|
||||
() => document.querySelector('[data-tutorial="routine-extend-time"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (isRoutineApprovedToday(routine)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-reset',
|
||||
() => document.querySelector('[data-tutorial="routine-reset"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeRoutineMenu() {
|
||||
@@ -665,7 +709,10 @@ const onDocClick = (e: MouseEvent) => {
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!inside) {
|
||||
const fromTutorial = path.some(
|
||||
(n) => n instanceof HTMLElement && n.classList.contains('tutorial-root'),
|
||||
)
|
||||
if (!inside && !fromTutorial) {
|
||||
activeMenuFor.value = null
|
||||
activeRoutineMenuFor.value = null
|
||||
selectedChoreId.value = null
|
||||
@@ -683,10 +730,45 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = kebabBtnRefs.value.get(taskId)
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeMenuFor.value = taskId
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-menu',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'kebab-edit-points-cost',
|
||||
() => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'chore-schedule',
|
||||
() => document.querySelector('[data-tutorial="chore-schedule"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const task = items.find((t) => t.id === taskId)
|
||||
if (task) {
|
||||
if (isChoreExpired(task)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-extend-time',
|
||||
() => document.querySelector('[data-tutorial="chore-extend-time"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (isChoreCompletedToday(task)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-reset',
|
||||
() => document.querySelector('[data-tutorial="chore-reset"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeChoreMenu() {
|
||||
@@ -859,9 +941,20 @@ watch(showOverrideModal, async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
document.getElementById('custom-value')?.focus()
|
||||
modalTutorialStepId.value = 'point-editor-help'
|
||||
tutorialMaybeShow(
|
||||
'point-editor-help',
|
||||
() => document.querySelector('input#custom-value') as HTMLElement | null,
|
||||
)
|
||||
} else {
|
||||
modalTutorialStepId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(showConfirm, (newVal) => setHelpButtonHidden(newVal))
|
||||
watch(showRewardConfirm, (newVal) => setHelpButtonHidden(newVal))
|
||||
watch(showRoutineConfirmDialog, (newVal) => setHelpButtonHidden(newVal))
|
||||
|
||||
async function saveOverride() {
|
||||
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
|
||||
|
||||
@@ -1002,6 +1095,11 @@ onMounted(async () => {
|
||||
child.value = data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
// Fire the per-child overview tour (chains into assign-* steps).
|
||||
// No anchor: this is a general overview so the card stays centered.
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow('select-child')
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
if (scrollToId) {
|
||||
@@ -1028,6 +1126,96 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Fire status-badge tutorials when those badges first render for any chore.
|
||||
watch(
|
||||
() => {
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
return items.map((t) => ({ id: t.id, expired: isChoreExpired(t), pending: isChorePending(t) }))
|
||||
},
|
||||
(list) => {
|
||||
if (list.some((t) => t.expired)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'status-too-late',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.chore-stamp')).find(
|
||||
(el) => (el.textContent || '').trim() === 'TOO LATE',
|
||||
) as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (list.some((t) => t.pending)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'status-pending',
|
||||
() => document.querySelector('.chore-stamp.pending-stamp') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// When the generic kebab-overview step fires, scroll to an assigned item
|
||||
// and select it so its kebab button is visible for the user to discover.
|
||||
watch(
|
||||
() => tutorialActiveStep.value?.def.id,
|
||||
async (stepId, prevStepId) => {
|
||||
if (stepId === 'item-kebab-overview') {
|
||||
const chores: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const routines: ChildRoutine[] = childRoutineListRef.value?.items ?? []
|
||||
if (chores.length > 0 && childChoreListRef.value) {
|
||||
const first = chores[0]
|
||||
childChoreListRef.value.scrollToItem(first.id)
|
||||
selectedChoreId.value = first.id
|
||||
tutorialHighlightedItemId.value = first.id
|
||||
await nextTick()
|
||||
const btn = kebabBtnRefs.value.get(first.id)
|
||||
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
||||
tutorialActiveStep.value.anchor = () => btn
|
||||
}
|
||||
} else if (routines.length > 0 && childRoutineListRef.value) {
|
||||
const first = routines[0]
|
||||
childRoutineListRef.value.scrollToItem(first.id)
|
||||
selectedRoutineId.value = first.id
|
||||
tutorialHighlightedItemId.value = first.id
|
||||
await nextTick()
|
||||
const btn = routineKebabBtnRefs.value.get(first.id)
|
||||
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
||||
tutorialActiveStep.value.anchor = () => btn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the highlighted selection when the tutorial leaves kebab steps
|
||||
const kebabStepIds = new Set([
|
||||
'item-kebab-overview',
|
||||
'chore-kebab-menu',
|
||||
'chore-edit-points',
|
||||
'chore-schedule',
|
||||
'chore-kebab-extend-time',
|
||||
'chore-kebab-reset',
|
||||
'routine-kebab-menu',
|
||||
'routine-edit',
|
||||
'routine-edit-points',
|
||||
'routine-schedule',
|
||||
'routine-extend-time',
|
||||
'routine-reset',
|
||||
'kebab-edit-points-cost',
|
||||
])
|
||||
if (
|
||||
prevStepId &&
|
||||
kebabStepIds.has(prevStepId) &&
|
||||
!kebabStepIds.has(stepId ?? '') &&
|
||||
tutorialHighlightedItemId.value
|
||||
) {
|
||||
selectedChoreId.value = null
|
||||
selectedRoutineId.value = null
|
||||
tutorialHighlightedItemId.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
@@ -1304,11 +1492,12 @@ function goToAssignRoutines() {
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
<button class="menu-item" data-tutorial="chore-edit-points kebab-edit-points-cost" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
Edit Points
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="chore-schedule"
|
||||
@mousedown.stop.prevent
|
||||
@click="openScheduleModal(item, $event)"
|
||||
>
|
||||
@@ -1317,6 +1506,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isChoreExpired(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="chore-extend-time"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendTime(item, $event)"
|
||||
>
|
||||
@@ -1325,6 +1515,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isChoreCompletedToday(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="chore-reset"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetChore(item, $event)"
|
||||
>
|
||||
@@ -1413,11 +1604,12 @@ function goToAssignRoutines() {
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
<button class="menu-item" data-tutorial="routine-edit" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
Edit Routine
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="routine-edit-points kebab-edit-points-cost"
|
||||
@mousedown.stop.prevent
|
||||
@click="editRoutinePoints(item)"
|
||||
>
|
||||
@@ -1425,6 +1617,7 @@ function goToAssignRoutines() {
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="routine-schedule"
|
||||
@mousedown.stop.prevent
|
||||
@click="openRoutineScheduleModal(item, $event)"
|
||||
>
|
||||
@@ -1433,6 +1626,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isRoutineExpired(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="routine-extend-time"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendRoutineTime(item, $event)"
|
||||
>
|
||||
@@ -1441,6 +1635,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isRoutineApprovedToday(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="routine-reset"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetRoutine(item, $event)"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton aria-label="Create Penalty" @click="goToCreate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +37,7 @@ import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import '@/assets/styles.css'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton aria-label="Create Reward" @click="goToCreateReward" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,6 +37,7 @@ import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import '@/assets/styles.css'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
@@ -60,7 +63,7 @@ async function onSubmit() {
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update rewards')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update rewards.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<template>
|
||||
<ModalDialog v-if="reward" @backdrop-click="$emit('cancel')">
|
||||
<div class="approve-dialog">
|
||||
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
|
||||
<p class="item-label">{{ reward.name }}</p>
|
||||
<p class="subtitle">
|
||||
{{ reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points' }}
|
||||
</p>
|
||||
<p class="message">
|
||||
Redeem this reward for <strong>{{ childName }}</strong
|
||||
<ModalDialog
|
||||
v-if="reward"
|
||||
title="Grant Reward"
|
||||
:subtitle="reward.name"
|
||||
:imageUrl="reward.image_url"
|
||||
@backdrop-click="$emit('cancel')"
|
||||
>
|
||||
<div class="modal-message">
|
||||
Redeem this reward for <span class="child-name">{{ childName }}</span
|
||||
>?
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
|
||||
<button v-if="reward.redeeming" @click="$emit('deny')" class="btn btn-secondary">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="$emit('confirm')">Yes</button>
|
||||
<button v-if="reward.redeeming" class="btn btn-secondary" @click="$emit('deny')">
|
||||
Reject
|
||||
</button>
|
||||
<button v-else @click="$emit('cancel')" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
<button v-else class="btn btn-secondary" @click="$emit('cancel')">No</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -38,54 +37,14 @@ defineEmits<{
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approve-dialog {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.reward-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--info-image-bg);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--dialog-child-name);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
.modal-message {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1rem;
|
||||
color: var(--modal-message-color, #333);
|
||||
}
|
||||
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
color: var(--dialog-child-name);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
color: var(--dialog-message);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 0.7rem 1.8rem;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
transition: background 0.18s;
|
||||
min-width: 100px;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
{{ isLoading ? 'Saving...' : 'Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FloatingActionButton aria-label="Create Routine" @click="goToCreate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +41,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { setChildRoutines } from '@/common/api'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import '@/assets/styles.css'
|
||||
import type { Routine } from '@/common/models'
|
||||
@@ -69,7 +72,7 @@ async function fetchRoutines() {
|
||||
const routinesData = await routinesResp.json()
|
||||
const rawRoutines: Routine[] = routinesData.routines || []
|
||||
await Promise.all(
|
||||
rawRoutines.map(async (r: any) => {
|
||||
rawRoutines.map(async (r: Routine) => {
|
||||
if (r.image_id) {
|
||||
try {
|
||||
r.image_url = await getCachedImageUrl(r.image_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ModalDialog
|
||||
v-if="task"
|
||||
title="Confirm Task"
|
||||
:title="dialogTitle"
|
||||
:subtitle="task.name"
|
||||
:imageUrl="task.image_url"
|
||||
@backdrop-click="$emit('cancel')"
|
||||
@@ -19,14 +19,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import type { Task } from '@/common/models'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
task: Task | null
|
||||
childName?: string
|
||||
}>()
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.task?.type === 'kindness') return 'Confirm Act'
|
||||
if (props.task?.type === 'penalty') return 'Confirm Penalty'
|
||||
return 'Confirm Task'
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import ChildView from '../ChildView.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { helpButtonHidden } from '@/tutorial/controller'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -396,6 +397,23 @@ describe('ChildView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help FAB visibility during reward redeem dialog', () => {
|
||||
beforeEach(() => {
|
||||
helpButtonHidden.value = false
|
||||
wrapper = mount(ChildView)
|
||||
})
|
||||
|
||||
it('hides the help button while the reward redeem dialog is open', async () => {
|
||||
wrapper.vm.showRewardDialog = true
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(true)
|
||||
|
||||
wrapper.vm.showRewardDialog = false
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Pending Reward Dialog', () => {
|
||||
const pendingReward = {
|
||||
id: 'reward-1',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, defineComponent } from 'vue'
|
||||
import ParentView from '../ParentView.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { helpButtonHidden } from '@/tutorial/controller'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -539,6 +540,43 @@ describe('ParentView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help FAB visibility during dialogs', () => {
|
||||
beforeEach(() => {
|
||||
helpButtonHidden.value = false
|
||||
wrapper = mount(ParentView, mountOptions)
|
||||
})
|
||||
|
||||
it('hides the help button while the task confirm dialog is open', async () => {
|
||||
wrapper.vm.showConfirm = true
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(true)
|
||||
|
||||
wrapper.vm.showConfirm = false
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the help button while the reward confirm dialog is open', async () => {
|
||||
wrapper.vm.showRewardConfirm = true
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(true)
|
||||
|
||||
wrapper.vm.showRewardConfirm = false
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the help button while the routine confirm dialog is open', async () => {
|
||||
wrapper.vm.showRoutineConfirmDialog = true
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(true)
|
||||
|
||||
wrapper.vm.showRoutineConfirmDialog = false
|
||||
await nextTick()
|
||||
expect(helpButtonHidden.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Highlight pulse animation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import RewardConfirmDialog from '../RewardConfirmDialog.vue'
|
||||
import type { RewardStatus } from '@/common/models'
|
||||
|
||||
const ModalDialogStub = {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['title', 'subtitle', 'imageUrl'],
|
||||
}
|
||||
|
||||
describe('RewardConfirmDialog', () => {
|
||||
it('renders "Grant Reward" title and reward name subtitle', () => {
|
||||
const reward: RewardStatus = {
|
||||
id: 'reward-1',
|
||||
name: 'Ice Cream',
|
||||
cost: 50,
|
||||
points_needed: 0,
|
||||
redeeming: false,
|
||||
image_id: '',
|
||||
}
|
||||
const wrapper = mount(RewardConfirmDialog, {
|
||||
props: { reward, childName: 'Test Child' },
|
||||
global: { stubs: { ModalDialog: ModalDialogStub } },
|
||||
})
|
||||
|
||||
const dialog = wrapper.findComponent(ModalDialogStub)
|
||||
expect(dialog.props('title')).toBe('Grant Reward')
|
||||
expect(dialog.props('subtitle')).toBe('Ice Cream')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TaskConfirmDialog from '../TaskConfirmDialog.vue'
|
||||
import type { Task } from '@/common/models'
|
||||
|
||||
const ModalDialogStub = {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['title', 'subtitle', 'imageUrl'],
|
||||
}
|
||||
|
||||
describe('TaskConfirmDialog', () => {
|
||||
it('renders "Confirm Task" title for chores', () => {
|
||||
const task: Task = {
|
||||
id: 'task-1',
|
||||
name: 'Clean Room',
|
||||
type: 'chore',
|
||||
points: 5,
|
||||
image_id: '',
|
||||
}
|
||||
const wrapper = mount(TaskConfirmDialog, {
|
||||
props: { task, childName: 'Test Child' },
|
||||
global: { stubs: { ModalDialog: ModalDialogStub } },
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Task')
|
||||
})
|
||||
|
||||
it('renders "Confirm Act" title for kindness acts', () => {
|
||||
const task: Task = {
|
||||
id: 'task-2',
|
||||
name: 'Share Toys',
|
||||
type: 'kindness',
|
||||
points: 3,
|
||||
image_id: '',
|
||||
}
|
||||
const wrapper = mount(TaskConfirmDialog, {
|
||||
props: { task, childName: 'Test Child' },
|
||||
global: { stubs: { ModalDialog: ModalDialogStub } },
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Act')
|
||||
})
|
||||
|
||||
it('renders "Confirm Penalty" title for penalties', () => {
|
||||
const task: Task = {
|
||||
id: 'task-3',
|
||||
name: 'No Screen Time',
|
||||
type: 'penalty',
|
||||
points: 5,
|
||||
image_id: '',
|
||||
}
|
||||
const wrapper = mount(TaskConfirmDialog, {
|
||||
props: { task, childName: 'Test Child' },
|
||||
global: { stubs: { ModalDialog: ModalDialogStub } },
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(ModalDialogStub).props('title')).toBe('Confirm Penalty')
|
||||
})
|
||||
})
|
||||
@@ -35,10 +35,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import { maybeShow as tutorialMaybeShow, tutorialReady } from '@/tutorial/controller'
|
||||
import type {
|
||||
PendingConfirmation,
|
||||
Event,
|
||||
@@ -87,6 +88,15 @@ function handleChoreConfirmation(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([notificationListCountRef, tutorialReady], ([count, ready]) => {
|
||||
if (ready && typeof count === 'number' && count > 0) {
|
||||
tutorialMaybeShow(
|
||||
'notification-click',
|
||||
() => document.querySelector('.notification-view .list-item') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<button
|
||||
type="button"
|
||||
class="section-header"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="contentId"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span class="section-title">{{ title }}</span>
|
||||
<span class="section-chevron" :class="{ open: isOpen }" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<div
|
||||
:id="contentId"
|
||||
ref="contentRef"
|
||||
class="section-body"
|
||||
:class="{ open: isOpen }"
|
||||
:style="bodyStyle"
|
||||
:inert="!isOpen"
|
||||
:aria-hidden="!isOpen"
|
||||
>
|
||||
<div ref="innerRef" class="section-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
defaultOpen?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = ref(props.defaultOpen ?? false)
|
||||
const contentRef = ref<HTMLDivElement | null>(null)
|
||||
const innerRef = ref<HTMLDivElement | null>(null)
|
||||
const bodyHeight = ref<number | null>(null)
|
||||
|
||||
const contentId = computed(() => `section-${props.title.toLowerCase().replace(/\s+/g, '-')}`)
|
||||
|
||||
const bodyStyle = computed(() => {
|
||||
if (isOpen.value && bodyHeight.value !== null) {
|
||||
return {
|
||||
maxHeight: `${bodyHeight.value}px`,
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
}
|
||||
}
|
||||
return {
|
||||
maxHeight: '0px',
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
}
|
||||
})
|
||||
|
||||
async function measureHeight() {
|
||||
await nextTick()
|
||||
if (!contentRef.value || !innerRef.value) return
|
||||
// The outer body is currently collapsed (max-height:0 / visibility:hidden),
|
||||
// so scrollHeight would read 0. Briefly unclamp it off-screen to measure the
|
||||
// real content height, then restore the closed styles.
|
||||
const el = contentRef.value
|
||||
const original = {
|
||||
maxHeight: el.style.maxHeight,
|
||||
visibility: el.style.visibility,
|
||||
position: el.style.position,
|
||||
}
|
||||
el.style.maxHeight = 'none'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.position = 'absolute'
|
||||
const height = innerRef.value.scrollHeight
|
||||
el.style.maxHeight = original.maxHeight
|
||||
el.style.visibility = original.visibility
|
||||
el.style.position = original.position
|
||||
bodyHeight.value = height
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
measureHeight()
|
||||
}
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (isOpen.value) {
|
||||
measureHeight()
|
||||
}
|
||||
window.addEventListener('resize', measureHeight)
|
||||
|
||||
if (innerRef.value && 'ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (isOpen.value) {
|
||||
measureHeight()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(innerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', measureHeight)
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-section {
|
||||
border-bottom: 1px solid var(--form-input-border, #e6e6e6);
|
||||
}
|
||||
|
||||
.profile-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--form-heading, #667eea);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section-header:focus-visible {
|
||||
outline: 2px solid var(--btn-primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-chevron {
|
||||
display: inline-block;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary, #888);
|
||||
transition: transform 0.25s ease;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.section-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.section-body {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding-bottom: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,51 @@
|
||||
<template>
|
||||
<div class="view">
|
||||
<EntityEditForm
|
||||
entityLabel="User Profile"
|
||||
:fields="fields"
|
||||
:initialData="initialData"
|
||||
:isEdit="true"
|
||||
:loading="loading"
|
||||
:error="errorMsg"
|
||||
:title="'User Profile'"
|
||||
:fieldErrors="{ push_enabled: pushError }"
|
||||
@submit="handleSubmit"
|
||||
@cancel="router.back"
|
||||
<h2>Profile</h2>
|
||||
|
||||
<div v-if="loading" class="loading-message">Loading profile...</div>
|
||||
<div v-else class="profile-card">
|
||||
<div v-if="errorMsg" class="error-banner" aria-live="polite">{{ errorMsg }}</div>
|
||||
|
||||
<ProfileSection title="User" :defaultOpen="true">
|
||||
<div class="name-fields" @focusout="handleNameFocusOut">
|
||||
<div class="field-group">
|
||||
<label for="first-name">First Name</label>
|
||||
<input
|
||||
id="first-name"
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="last-name">Last Name</label>
|
||||
<input
|
||||
id="last-name"
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Image</label>
|
||||
<ImagePicker
|
||||
:modelValue="imageId"
|
||||
@update:modelValue="onImageChange"
|
||||
@add-image="onAddImage"
|
||||
>
|
||||
<template #custom-field-email="{ modelValue }">
|
||||
<div class="email-actions">
|
||||
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
||||
:image-type="1"
|
||||
/>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection title="Account">
|
||||
<div class="field-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input id="email" type="email" :value="email" disabled class="readonly-input" />
|
||||
</div>
|
||||
<div class="action-links">
|
||||
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
||||
Change Parent PIN
|
||||
</button>
|
||||
@@ -31,10 +61,42 @@
|
||||
Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</EntityEditForm>
|
||||
</ProfileSection>
|
||||
|
||||
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
|
||||
<ProfileSection title="Notifications">
|
||||
<div class="toggle-stack">
|
||||
<ToggleField
|
||||
label="Daily Digest"
|
||||
:modelValue="emailDigestEnabled"
|
||||
@update:modelValue="onToggleDigest"
|
||||
:disabled="saving"
|
||||
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
|
||||
/>
|
||||
<ToggleField
|
||||
label="Push Notifications"
|
||||
:modelValue="pushEnabled"
|
||||
@update:modelValue="onTogglePush"
|
||||
:disabled="saving || pushPermissionDenied"
|
||||
description="Receive instant push notifications when a chore or reward needs your approval."
|
||||
:error="pushError"
|
||||
/>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection title="Help">
|
||||
<ToggleField
|
||||
label="Show tutorial tips"
|
||||
:modelValue="tutorialEnabled"
|
||||
@update:modelValue="onToggleTutorial"
|
||||
description="Show helpful tips as I use the app."
|
||||
/>
|
||||
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
|
||||
Restart tutorial
|
||||
</button>
|
||||
</ProfileSection>
|
||||
</div>
|
||||
|
||||
<!-- Password reset modal -->
|
||||
<ModalDialog
|
||||
v-if="showModal"
|
||||
:title="modalTitle"
|
||||
@@ -95,14 +157,43 @@
|
||||
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Restart confirmation -->
|
||||
<ModalDialog
|
||||
v-if="showRestartConfirm"
|
||||
title="Tutorial Restart"
|
||||
@close="showRestartConfirm = false"
|
||||
>
|
||||
<div class="modal-message">
|
||||
Start the tour again from the beginning? You'll see the tips again as you use the app.
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRestartConfirm = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmRestartTutorial">Restart</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Restart success -->
|
||||
<ModalDialog
|
||||
v-if="showRestartSuccess"
|
||||
title="Tutorial Restart"
|
||||
@close="showRestartSuccess = false"
|
||||
>
|
||||
<div class="modal-message">Tutorial mode has been restarted.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import ProfileSection from './ProfileSection.vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import ToggleField from '@/components/shared/ToggleField.vue'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
import {
|
||||
isSubscribedToPush,
|
||||
subscribeToPushWithResult,
|
||||
@@ -113,19 +204,34 @@ import {
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { ALREADY_MARKED } from '@/common/errorCodes'
|
||||
import { logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||
import { tutorialEnabled, setTutorialEnabled, resetAllProgress } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
// Profile data
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const resetting = ref(false)
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const imageId = ref<string | null>(null)
|
||||
const email = ref('')
|
||||
const emailDigestEnabled = ref(true)
|
||||
const pushEnabled = ref(false)
|
||||
const pushPermissionDenied = ref(false)
|
||||
const pushError = ref('')
|
||||
|
||||
const localImageFile = ref<File | null>(null)
|
||||
|
||||
// Modal state
|
||||
const resetting = ref(false)
|
||||
const showModal = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalSubtitle = ref('')
|
||||
const modalMessage = ref('')
|
||||
|
||||
// Delete account modal state
|
||||
const showDeleteWarning = ref(false)
|
||||
const confirmEmail = ref('')
|
||||
const deletingAccount = ref(false)
|
||||
@@ -133,46 +239,10 @@ const showDeleteSuccess = ref(false)
|
||||
const showDeleteError = ref(false)
|
||||
const deleteErrorMessage = ref('')
|
||||
|
||||
const pushError = ref('')
|
||||
const pushPermissionDenied = ref(false)
|
||||
|
||||
const initialData = ref<{
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled: boolean
|
||||
push_enabled: boolean
|
||||
}>({
|
||||
image_id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
email_digest_enabled: true,
|
||||
push_enabled: false,
|
||||
})
|
||||
|
||||
const fields = computed(() => [
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
|
||||
{ name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'email', label: 'Email Address', type: 'custom' as const },
|
||||
{
|
||||
name: 'email_digest_enabled',
|
||||
label: 'Daily Digest',
|
||||
type: 'toggle' as const,
|
||||
description:
|
||||
'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
|
||||
},
|
||||
{
|
||||
name: 'push_enabled',
|
||||
label: 'Push Notifications',
|
||||
type: 'toggle' as const,
|
||||
description: 'Receive instant push notifications when a chore or reward needs your approval.',
|
||||
disabled: pushPermissionDenied.value,
|
||||
},
|
||||
])
|
||||
const showRestartConfirm = ref(false)
|
||||
const showRestartSuccess = ref(false)
|
||||
|
||||
// Load profile
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -181,14 +251,12 @@ onMounted(async () => {
|
||||
const data = await res.json()
|
||||
pushPermissionDenied.value = getPushPermissionState() === 'denied'
|
||||
const pushSubscribed = await isSubscribedToPush()
|
||||
initialData.value = {
|
||||
image_id: data.image_id || null,
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
email_digest_enabled: data.email_digest_enabled !== false,
|
||||
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
|
||||
}
|
||||
firstName.value = data.first_name || ''
|
||||
lastName.value = data.last_name || ''
|
||||
imageId.value = data.image_id || null
|
||||
email.value = data.email || ''
|
||||
emailDigestEnabled.value = data.email_digest_enabled !== false
|
||||
pushEnabled.value = data.push_notifications_enabled !== false && pushSubscribed
|
||||
} catch {
|
||||
errorMsg.value = 'Could not load user profile.'
|
||||
} finally {
|
||||
@@ -196,102 +264,140 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
initialData.value.image_id = id
|
||||
function handleNameFocusOut(event: FocusEvent) {
|
||||
const wrapper = event.currentTarget as HTMLElement
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null
|
||||
if (relatedTarget && wrapper.contains(relatedTarget)) {
|
||||
return
|
||||
}
|
||||
saveNames()
|
||||
}
|
||||
|
||||
// ─── Auto-save: names ───
|
||||
async function saveNames() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.value,
|
||||
last_name: lastName.value,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(form: {
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
// ─── Auto-save: image ───
|
||||
function onImageChange(id: string | null) {
|
||||
if (id === 'local-upload') {
|
||||
// localImageFile is set by onAddImage which fires first
|
||||
uploadLocalImage()
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
imageId.value = id
|
||||
saveImage(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image upload if local file
|
||||
let imageId = form.image_id
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLocalImage() {
|
||||
if (!localImageFile.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '1')
|
||||
formData.append('permanent', 'true')
|
||||
fetch('/api/image/upload', {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(async (resp) => {
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
// Now update profile
|
||||
return updateProfile({
|
||||
...form,
|
||||
image_id: imageId,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
imageId.value = data.id
|
||||
await saveImage(data.id)
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to upload image.'
|
||||
loading.value = false
|
||||
})
|
||||
} else {
|
||||
updateProfile(form)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(form: {
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
const prevDigest = initialData.value.email_digest_enabled
|
||||
const prevPush = initialData.value.push_enabled
|
||||
async function saveImage(id: string | null) {
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
first_name: form.first_name,
|
||||
last_name: form.last_name,
|
||||
image_id: form.image_id,
|
||||
}
|
||||
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
|
||||
body.email_digest_enabled = form.email_digest_enabled
|
||||
}
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
body.push_notifications_enabled = form.push_enabled
|
||||
}
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ image_id: id }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
let actualPushEnabled = prevPush
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
const pushOk = await applyPushChange(form.push_enabled)
|
||||
actualPushEnabled = pushOk ? form.push_enabled : prevPush
|
||||
}
|
||||
initialData.value = {
|
||||
...initialData.value,
|
||||
...form,
|
||||
push_enabled: actualPushEnabled,
|
||||
}
|
||||
modalTitle.value = 'Profile Updated'
|
||||
modalSubtitle.value = ''
|
||||
modalMessage.value = 'Your profile was updated successfully.'
|
||||
showModal.value = true
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auto-save: toggles ───
|
||||
async function onToggleDigest(val: boolean) {
|
||||
emailDigestEnabled.value = val
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email_digest_enabled: val }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
emailDigestEnabled.value = !val
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTogglePush(val: boolean) {
|
||||
const prevPush = pushEnabled.value
|
||||
pushEnabled.value = val
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
pushError.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ push_notifications_enabled: val }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
const pushOk = await applyPushChange(val)
|
||||
if (!pushOk) {
|
||||
pushEnabled.value = prevPush
|
||||
}
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
pushEnabled.value = prevPush
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,16 +426,13 @@ async function applyPushChange(newValue: boolean): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordModalClose() {
|
||||
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
|
||||
showModal.value = false
|
||||
if (wasProfileUpdate) {
|
||||
router.back()
|
||||
}
|
||||
// ─── Tutorial toggle ───
|
||||
async function onToggleTutorial(val: boolean) {
|
||||
await setTutorialEnabled(val)
|
||||
}
|
||||
|
||||
// ─── Password reset ───
|
||||
async function resetPassword() {
|
||||
// Show modal immediately with loading message
|
||||
modalTitle.value = 'Change Password'
|
||||
modalMessage.value = 'Sending password change email...'
|
||||
modalSubtitle.value = ''
|
||||
@@ -340,7 +443,7 @@ async function resetPassword() {
|
||||
const res = await fetch('/api/auth/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: initialData.value.email }),
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to send reset email')
|
||||
modalTitle.value = 'Password Change Email Sent'
|
||||
@@ -354,10 +457,16 @@ async function resetPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordModalClose() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// ─── Navigation ───
|
||||
function goToChangeParentPin() {
|
||||
router.push({ name: 'ParentPinSetup' })
|
||||
}
|
||||
|
||||
// ─── Delete account ───
|
||||
function openDeleteWarning() {
|
||||
confirmEmail.value = ''
|
||||
showDeleteWarning.value = true
|
||||
@@ -370,9 +479,6 @@ function closeDeleteWarning() {
|
||||
|
||||
async function confirmDeleteAccount() {
|
||||
if (!isEmailValid(confirmEmail.value)) return
|
||||
|
||||
// Set flag before the request so it's guaranteed to be set
|
||||
// before the force_logout SSE event can arrive on this tab
|
||||
suppressForceLogout.value = true
|
||||
deletingAccount.value = true
|
||||
try {
|
||||
@@ -381,7 +487,6 @@ async function confirmDeleteAccount() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: confirmEmail.value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
suppressForceLogout.value = false
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
@@ -394,8 +499,6 @@ async function confirmDeleteAccount() {
|
||||
showDeleteError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Success — suppressForceLogout is already set; show confirmation modal
|
||||
showDeleteWarning.value = false
|
||||
showDeleteSuccess.value = true
|
||||
} catch {
|
||||
@@ -410,12 +513,10 @@ async function confirmDeleteAccount() {
|
||||
|
||||
function handleDeleteSuccess() {
|
||||
showDeleteSuccess.value = false
|
||||
// Call logout API to clear server cookies
|
||||
fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).finally(() => {
|
||||
// Clear client-side auth and redirect, regardless of logout response
|
||||
logoutUser()
|
||||
router.push('/')
|
||||
})
|
||||
@@ -425,65 +526,134 @@ function closeDeleteError() {
|
||||
showDeleteError.value = false
|
||||
deleteErrorMessage.value = ''
|
||||
}
|
||||
|
||||
// ─── Tutorial restart ───
|
||||
function openRestartConfirm() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmRestartTutorial() {
|
||||
showRestartConfirm.value = false
|
||||
await resetAllProgress()
|
||||
showRestartSuccess.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view {
|
||||
max-width: 400px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: var(--form-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px var(--form-shadow);
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
/* ...existing styles... */
|
||||
.email-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
color: var(--loading-color, #888);
|
||||
font-size: 1rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.error-message {
|
||||
|
||||
.error-banner {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.98rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.6rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.readonly-input {
|
||||
|
||||
.field-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.field-group input[type='text'],
|
||||
.field-group input[type='email'] {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #f5f5f5);
|
||||
color: var(--form-label, #888);
|
||||
background: var(--form-input-bg, #fff);
|
||||
color: var(--text-primary, #222);
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-danger-link {
|
||||
.field-group input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.readonly-input {
|
||||
background: var(--form-input-bg, #f5f5f5) !important;
|
||||
color: var(--form-label, #888) !important;
|
||||
}
|
||||
|
||||
.action-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toggle-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--error, #e53e3e);
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
margin-top: 0.25rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-danger-link:hover {
|
||||
color: var(--error-hover, #c53030);
|
||||
.btn-link:hover {
|
||||
color: var(--btn-primary-hover, #5a67d8);
|
||||
}
|
||||
|
||||
.btn-danger-link:disabled {
|
||||
.btn-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
text-align: center;
|
||||
color: var(--dialog-message, #444);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.email-confirm-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
@@ -500,4 +670,18 @@ function closeDeleteError() {
|
||||
outline: none;
|
||||
border-color: var(--btn-primary, #4a90e2);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.profile-card {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.view {
|
||||
max-width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -46,6 +47,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-reward-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:data-idx="idx"
|
||||
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === idx }"
|
||||
>
|
||||
<span class="drag-handle" title="Drag to reorder" @pointerdown.prevent="(e) => onPointerDown(e, idx)">⠿</span>
|
||||
<span class="drag-handle" data-tutorial="routine-task-reorder" title="Drag to reorder" @pointerdown.prevent="(e) => onPointerDown(e, idx)">⠿</span>
|
||||
<div class="item-left">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
@@ -43,11 +43,17 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary small-btn"
|
||||
data-tutorial="routine-task-edit"
|
||||
@click="startEditItem(idx)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary small-btn" @click="removeItem(idx)">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary small-btn"
|
||||
data-tutorial="routine-task-delete"
|
||||
@click="removeItem(idx)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -122,6 +128,7 @@ import EntityEditForm from '@/components/shared/EntityEditForm.vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import type { RoutineItem } from '@/common/models'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -161,6 +168,7 @@ const draggingIdx = ref<number | null>(null)
|
||||
const dragOverIdx = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-routine-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
markStepSeen as tutorialMark,
|
||||
tutorialReady,
|
||||
isTutorialActive,
|
||||
} from '@/tutorial/controller'
|
||||
import type {
|
||||
Child,
|
||||
ChildModifiedEventPayload,
|
||||
@@ -136,10 +142,12 @@ const fetchChildren = async (): Promise<Child[]> => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
children.value = childList
|
||||
return childList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||
console.error('Error fetching children:', err)
|
||||
children.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -150,14 +158,35 @@ const createChild = () => {
|
||||
router.push({ name: 'CreateChild' })
|
||||
}
|
||||
|
||||
function maybeTriggerCreateChildTutorial() {
|
||||
if (!isParentAuthenticated.value) return
|
||||
if (loading.value) return
|
||||
if (!tutorialReady.value) return
|
||||
if (children.value.length === 0) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow('create-child')
|
||||
})
|
||||
} else {
|
||||
void tutorialMark('has-created-child')
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow('child-points')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch([tutorialReady, loading, children, isParentAuthenticated], maybeTriggerCreateChildTutorial, {
|
||||
immediate: false,
|
||||
deep: true,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_task_triggered', handleChildTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
||||
|
||||
const listPromise = fetchChildren()
|
||||
listPromise.then((list) => {
|
||||
children.value = list
|
||||
listPromise.then(() => {
|
||||
maybeTriggerCreateChildTutorial()
|
||||
})
|
||||
// listen for outside clicks to auto-close any open kebab menu
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
@@ -214,6 +243,10 @@ const selectChild = (childId: string | number) => {
|
||||
const openMenu = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
activeMenuFor.value = childId
|
||||
tutorialMaybeShow(
|
||||
'child-kebab',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
const closeMenu = () => {
|
||||
activeMenuFor.value = null
|
||||
@@ -356,6 +389,7 @@ onBeforeUnmount(() => {
|
||||
<FloatingActionButton
|
||||
v-if="isParentAuthenticated"
|
||||
aria-label="Add Child"
|
||||
:disabled="isTutorialActive"
|
||||
@click="createChild"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:label="field.label"
|
||||
:modelValue="formData[field.name]"
|
||||
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
|
||||
:disabled="field.disabled"
|
||||
:disabled="field.disabled || isTutorialActive"
|
||||
:description="field.description"
|
||||
:error="props.fieldErrors?.[field.name]"
|
||||
/>
|
||||
@@ -29,6 +29,7 @@
|
||||
type="text"
|
||||
:required="field.required"
|
||||
:maxlength="field.maxlength"
|
||||
:disabled="isTutorialActive"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
@@ -40,11 +41,13 @@
|
||||
:max="field.max"
|
||||
inputmode="numeric"
|
||||
pattern="\\d{1,3}"
|
||||
:disabled="isTutorialActive"
|
||||
@input="
|
||||
(e) => {
|
||||
if (field.maxlength && e.target.value.length > field.maxlength) {
|
||||
e.target.value = e.target.value.slice(0, field.maxlength)
|
||||
formData[field.name] = e.target.value
|
||||
(e: Event) => {
|
||||
const target = e.target as HTMLInputElement | null
|
||||
if (field.maxlength && target && target.value.length > field.maxlength) {
|
||||
target.value = target.value.slice(0, field.maxlength)
|
||||
formData[field.name] = target.value
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -54,6 +57,7 @@
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
:image-type="field.imageType || 1"
|
||||
:disabled="isTutorialActive"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</slot>
|
||||
@@ -81,7 +85,7 @@
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import ToggleField from './ToggleField.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isTutorialActive } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
type Field = {
|
||||
@@ -118,7 +122,6 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||
|
||||
const router = useRouter()
|
||||
const formData = ref<Record<string, any>>({ ...props.initialData })
|
||||
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
|
||||
<button class="fab" :disabled="disabled" @click="onClick" :aria-label="ariaLabel">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
@@ -8,7 +8,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ariaLabel?: string }>()
|
||||
const props = defineProps<{ ariaLabel?: string; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'click'): void }>()
|
||||
|
||||
function onClick() {
|
||||
if (!props.disabled) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -38,6 +45,10 @@ defineProps<{ ariaLabel?: string }>()
|
||||
.fab:active {
|
||||
background: var(--fab-active-bg);
|
||||
}
|
||||
.fab:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
isPushOptedOut,
|
||||
ensurePushSubscriptionSynced,
|
||||
} from '@/services/pushSubscription'
|
||||
import {
|
||||
hydrateFromProfile as hydrateTutorial,
|
||||
maybeShow as tutorialMaybeShow,
|
||||
} from '@/tutorial/controller'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
@@ -57,6 +61,11 @@ async function fetchUserProfile() {
|
||||
userImageId.value = data.image_id || null
|
||||
userFirstName.value = data.first_name || ''
|
||||
userEmail.value = data.email || ''
|
||||
hydrateTutorial({
|
||||
tutorial_enabled: data.tutorial_enabled,
|
||||
tutorial_progress: data.tutorial_progress,
|
||||
})
|
||||
void maybeShowSetupPinTutorial()
|
||||
|
||||
// Update avatar initial
|
||||
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
|
||||
@@ -83,6 +92,19 @@ async function fetchUserProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeShowSetupPinTutorial() {
|
||||
try {
|
||||
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (!data.has_pin) {
|
||||
tutorialMaybeShow('setup-parent-pin', () => avatarButtonRef.value)
|
||||
}
|
||||
} catch {
|
||||
// Silent: tutorial just won't fire.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvatarImages(imageId: string) {
|
||||
try {
|
||||
const blob = await getCachedImageBlob(imageId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
|
||||
<!-- Enable/disable toggle row -->
|
||||
<div class="schedule-toggle-row">
|
||||
<div class="schedule-toggle-row" data-tutorial="schedule-enable-toggle">
|
||||
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -58,7 +58,12 @@
|
||||
|
||||
<!-- Selected day exception list -->
|
||||
<div v-if="selectedDays.size > 0" class="exception-list">
|
||||
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
||||
<div
|
||||
v-for="(idx, i) in sortedSelectedDays"
|
||||
:key="idx"
|
||||
class="exception-row"
|
||||
:data-tutorial="i === 0 ? 'schedule-days-exception' : undefined"
|
||||
>
|
||||
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||||
<div class="exception-right">
|
||||
<template v-if="exceptions.has(idx)">
|
||||
@@ -114,7 +119,7 @@
|
||||
</div>
|
||||
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
||||
</div>
|
||||
<div class="interval-row">
|
||||
<div class="interval-row" data-tutorial="schedule-interval-start">
|
||||
<label class="field-label">Starting on</label>
|
||||
<DateInputField
|
||||
:modelValue="anchorDate"
|
||||
@@ -161,10 +166,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimePickerPopover from './TimePickerPopover.vue'
|
||||
import DateInputField from './DateInputField.vue'
|
||||
import { maybeShow as tutorialMaybeShow, modalTutorialStepId } from '@/tutorial/controller'
|
||||
import {
|
||||
setChoreSchedule,
|
||||
deleteChoreSchedule,
|
||||
@@ -258,6 +264,35 @@ const intervalTime = ref<TimeValue>({
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
function triggerScheduleTutorial(isDays: boolean) {
|
||||
modalTutorialStepId.value = isDays ? 'schedule-days-chips' : 'schedule-interval-frequency'
|
||||
nextTick(() => {
|
||||
if (isDays) {
|
||||
tutorialMaybeShow(
|
||||
'schedule-days-chips',
|
||||
() => document.querySelector('.day-chips') as HTMLElement | null,
|
||||
)
|
||||
} else {
|
||||
tutorialMaybeShow(
|
||||
'schedule-interval-frequency',
|
||||
() => document.querySelector('.stepper') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
triggerScheduleTutorial(mode.value === 'days')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
modalTutorialStepId.value = null
|
||||
})
|
||||
|
||||
watch(mode, (newMode) => {
|
||||
triggerScheduleTutorial(newMode === 'days')
|
||||
})
|
||||
|
||||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||||
|
||||
const origMode = props.schedule?.mode ?? 'days'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EntityEditForm from '../EntityEditForm.vue'
|
||||
import { activeStep } from '@/tutorial/controller'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
@@ -9,6 +10,69 @@ vi.mock('vue-router', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('EntityEditForm tutorial interaction', () => {
|
||||
beforeEach(() => {
|
||||
activeStep.value = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
activeStep.value = null
|
||||
})
|
||||
|
||||
it('disables text and number inputs while a tutorial step is active', async () => {
|
||||
activeStep.value = {
|
||||
def: {
|
||||
id: 'edit-child-name',
|
||||
title: "Child's Name",
|
||||
body: 'Tutorial body',
|
||||
},
|
||||
anchor: null,
|
||||
}
|
||||
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: { name: '', age: null },
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect((wrapper.find('#name').element as HTMLInputElement).disabled).toBe(true)
|
||||
expect((wrapper.find('#age').element as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps inputs enabled when no tutorial step is active', async () => {
|
||||
activeStep.value = null
|
||||
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: { name: '', age: null },
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect((wrapper.find('#name').element as HTMLInputElement).disabled).toBe(false)
|
||||
expect((wrapper.find('#age').element as HTMLInputElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EntityEditForm', () => {
|
||||
it('keeps Create disabled when required number field is empty', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FloatingActionButton from '../FloatingActionButton.vue'
|
||||
|
||||
describe('FloatingActionButton', () => {
|
||||
it('emits click when enabled and clicked', async () => {
|
||||
const wrapper = mount(FloatingActionButton, {
|
||||
props: { ariaLabel: 'Add Child', disabled: false },
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is disabled and does not emit click when disabled prop is true', async () => {
|
||||
const wrapper = mount(FloatingActionButton, {
|
||||
props: { ariaLabel: 'Add Child', disabled: true },
|
||||
})
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -36,6 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-chore-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -36,6 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-kindness-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -36,6 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-penalty-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | null // selected image id or local-upload
|
||||
imageType?: number // 1 or 2, default 1
|
||||
}>()
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits(['update:modelValue', 'add-image'])
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const imageScrollRef = ref<HTMLDivElement | null>(null)
|
||||
const addPhotoBtn = ref<HTMLButtonElement | null>(null)
|
||||
const cameraBtn = ref<HTMLButtonElement | null>(null)
|
||||
const localImageUrl = ref<string | null>(null)
|
||||
const showCamera = ref(false)
|
||||
const cameraStream = ref<MediaStream | null>(null)
|
||||
@@ -25,6 +34,7 @@ const loadingImages = ref(false)
|
||||
const typeParam = computed(() => props.imageType ?? 1)
|
||||
|
||||
const selectImage = (id: string | undefined) => {
|
||||
if (props.disabled) return
|
||||
if (!id) {
|
||||
console.warn('selectImage called with null id')
|
||||
return
|
||||
@@ -33,18 +43,20 @@ const selectImage = (id: string | undefined) => {
|
||||
}
|
||||
|
||||
const addFromLocal = () => {
|
||||
if (props.disabled) return
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileChange = async (event: Event) => {
|
||||
if (props.disabled) return
|
||||
const files = (event.target as HTMLInputElement).files
|
||||
if (files && files.length > 0) {
|
||||
if (!files || files.length === 0) return
|
||||
const file = files[0]
|
||||
if (!file) return
|
||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
||||
const { blob, url } = await resizeImageFile(file, 512)
|
||||
localImageUrl.value = url
|
||||
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -52,6 +64,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const addFromCamera = async () => {
|
||||
if (props.disabled) return
|
||||
cameraError.value = null
|
||||
capturedImageUrl.value = null
|
||||
showCamera.value = true
|
||||
@@ -149,9 +162,11 @@ onMounted(async () => {
|
||||
const idx = images.findIndex((img) => img.id === props.modelValue)
|
||||
if (idx > 0) {
|
||||
const [selected] = images.splice(idx, 1)
|
||||
if (selected) {
|
||||
images.unshift(selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
availableImages.value = images
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -196,8 +211,8 @@ function updateLocalImage(url: string, file: File) {
|
||||
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
|
||||
if (idx === -1) {
|
||||
availableImages.value.unshift({ id: 'local-upload', url })
|
||||
} else {
|
||||
availableImages.value[idx].url = url
|
||||
} else if (availableImages.value[idx]) {
|
||||
availableImages.value[idx]!.url = url
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
@@ -221,7 +236,7 @@ function updateLocalImage(url: string, file: File) {
|
||||
:key="img.id"
|
||||
:src="img.url"
|
||||
class="selectable-image"
|
||||
:class="{ selected: modelValue === img.id }"
|
||||
:class="{ selected: modelValue === img.id, disabled: props.disabled }"
|
||||
:alt="`Image ${img.id}`"
|
||||
@click="selectImage(img.id)"
|
||||
/>
|
||||
@@ -232,13 +247,14 @@ function updateLocalImage(url: string, file: File) {
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||
style="display: none"
|
||||
tabindex="-1"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
||||
<button ref="addPhotoBtn" type="button" class="icon-btn" :disabled="props.disabled" @click="addFromLocal" aria-label="Add from device">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
<button type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
|
||||
<button ref="cameraBtn" type="button" class="icon-btn" :disabled="props.disabled" @click="addFromCamera" aria-label="Add from camera">
|
||||
<span class="icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
||||
@@ -311,6 +327,10 @@ function updateLocalImage(url: string, file: File) {
|
||||
border-color: var(--selectable-image-selected);
|
||||
box-shadow: 0 0 0 2px #667eea55;
|
||||
}
|
||||
.selectable-image.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.loading-images {
|
||||
color: var(--loading-text);
|
||||
font-size: 0.98rem;
|
||||
@@ -340,6 +360,10 @@ function updateLocalImage(url: string, file: File) {
|
||||
color: var(--icon-btn-color);
|
||||
box-shadow: var(--icon-btn-shadow);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn svg {
|
||||
width: 32px; /* Bigger camera icon */
|
||||
height: 32px;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import ImagePicker from '../ImagePicker.vue'
|
||||
|
||||
vi.mock('@/common/imageCache', () => ({
|
||||
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
|
||||
revokeImageUrl: vi.fn(),
|
||||
revokeAllImageUrls: vi.fn(),
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
async function flushPromises() {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ImagePicker disabled behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ids: ['img1'] }),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders selectable images and action buttons', async () => {
|
||||
const wrapper = mount(ImagePicker, {
|
||||
props: { modelValue: null, imageType: 1, disabled: false },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.selectable-image').length).toBeGreaterThan(0)
|
||||
expect(wrapper.find('button[aria-label="Add from device"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="Add from camera"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disables image selection and action buttons when disabled is true', async () => {
|
||||
const wrapper = mount(ImagePicker, {
|
||||
props: { modelValue: null, imageType: 1, disabled: true },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const image = wrapper.find('.selectable-image')
|
||||
expect(image.classes()).toContain('disabled')
|
||||
|
||||
const addFromDevice = wrapper.find('button[aria-label="Add from device"]')
|
||||
const addFromCamera = wrapper.find('button[aria-label="Add from camera"]')
|
||||
expect((addFromDevice.element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((addFromCamera.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not emit update:modelValue when a disabled image is clicked', async () => {
|
||||
const wrapper = mount(ImagePicker, {
|
||||
props: { modelValue: null, imageType: 1, disabled: true },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const image = wrapper.find('.selectable-image')
|
||||
await image.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not open file input when Add from device is clicked while disabled', async () => {
|
||||
const wrapper = mount(ImagePicker, {
|
||||
props: { modelValue: null, imageType: 1, disabled: true },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]').element as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
await wrapper.find('button[aria-label="Add from device"]').trigger('click')
|
||||
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the file input when Add from device is clicked while enabled', async () => {
|
||||
const wrapper = mount(ImagePicker, {
|
||||
props: { modelValue: null, imageType: 1, disabled: false },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]').element as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
await wrapper.find('button[aria-label="Add from device"]').trigger('click')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="visible"
|
||||
type="button"
|
||||
class="help-fab"
|
||||
aria-label="Show help for this screen"
|
||||
@click="onClick"
|
||||
title="Show help"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { activeStep, maybeShow, clearChainProgress, modalTutorialStepId, helpButtonHidden, sessionSkipped } from './controller'
|
||||
|
||||
// Map route names to the tutorial step that should re-fire when the user taps `?`.
|
||||
// Keep this list lean — only routes that have a tutorial step actually wired.
|
||||
const routeToStep: Record<string, string> = {
|
||||
ParentChildrenListView: 'parent-children-list',
|
||||
ChoreView: 'list-chore-help',
|
||||
KindnessView: 'list-kindness-help',
|
||||
PenaltyView: 'list-penalty-help',
|
||||
RewardView: 'list-reward-help',
|
||||
RoutineView: 'list-routine-help',
|
||||
NotificationView: 'notification-click',
|
||||
ParentView: 'select-child',
|
||||
CreateChore: 'edit-chore-name',
|
||||
EditChore: 'edit-chore-name',
|
||||
CreateKindness: 'edit-kindness-name',
|
||||
EditKindness: 'edit-kindness-name',
|
||||
CreatePenalty: 'edit-penalty-name',
|
||||
EditPenalty: 'edit-penalty-name',
|
||||
CreateRoutine: 'edit-routine-name',
|
||||
EditRoutine: 'edit-routine-name',
|
||||
CreateReward: 'edit-reward-name',
|
||||
EditReward: 'edit-reward-name',
|
||||
CreateChild: 'edit-child-name',
|
||||
ChildEditView: 'edit-child-name',
|
||||
ChoreAssignView: 'assign-chore-list',
|
||||
KindnessAssignView: 'assign-kindness-list',
|
||||
PenaltyAssignView: 'assign-penalty-list',
|
||||
RewardAssignView: 'assign-reward-list',
|
||||
RoutineAssignView: 'assign-routine-list',
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const targetStepId = computed<string | null>(() => {
|
||||
// Modals (e.g. ScheduleModal) can override the route-based step.
|
||||
if (modalTutorialStepId.value) return modalTutorialStepId.value
|
||||
const name = typeof route.name === 'string' ? route.name : String(route.name ?? '')
|
||||
return routeToStep[name] ?? null
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
if (helpButtonHidden.value) return false
|
||||
return targetStepId.value !== null
|
||||
})
|
||||
|
||||
function onClick() {
|
||||
const id = targetStepId.value
|
||||
if (!id) return
|
||||
// Clear any active step so the manual re-fire wins.
|
||||
activeStep.value = null
|
||||
// A previous Cancel/Skip would otherwise permanently block manual help.
|
||||
sessionSkipped.value = false
|
||||
// Temporarily clear local "seen" state for the whole chain so every step replays.
|
||||
// The server state is left alone; on dismiss `markStepSeen` simply no-ops.
|
||||
clearChainProgress(id)
|
||||
maybeShow(id, null, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
background: var(--btn-secondary, rgba(255, 255, 255, 0.92));
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1300;
|
||||
transition:
|
||||
background 0.18s,
|
||||
box-shadow 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
.help-fab:hover {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.help-fab:focus-visible {
|
||||
outline: 2px solid var(--primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.help-fab {
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 1.15rem;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.help-fab:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<ModalDialog v-if="visible" title="Want to see what your child sees?" @close="onLater">
|
||||
<div class="modal-message">
|
||||
Take a quick peek at child mode so you can show them how it works.
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="onNoThanks">No thanks</button>
|
||||
<button class="btn btn-secondary" @click="onLater">Maybe later</button>
|
||||
<button class="btn btn-primary" @click="onYes">Yes, show me</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
import {
|
||||
tutorialEnabled,
|
||||
tutorialProgress,
|
||||
tutorialReady,
|
||||
markStepSeen,
|
||||
maybeShow,
|
||||
} from './controller'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// "Maybe later" is session-only: don't persist, just don't re-prompt this session.
|
||||
const dismissedThisSession = ref(false)
|
||||
|
||||
const prereqsMet = computed(() => {
|
||||
const p = tutorialProgress.value
|
||||
return !!p['has-created-child'] && !!p['has-created-chore'] && !!p['has-created-reward']
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
if (!tutorialEnabled.value) return false
|
||||
if (!tutorialReady.value) return false
|
||||
if (dismissedThisSession.value) return false
|
||||
if (tutorialProgress.value['child-mode-tour-offer']) return false
|
||||
return prereqsMet.value
|
||||
})
|
||||
|
||||
// Avoid showing the modal on top of an active coach mark — wait for it to clear.
|
||||
const showWhenIdle = ref(false)
|
||||
watch(visible, (v) => {
|
||||
showWhenIdle.value = v
|
||||
})
|
||||
|
||||
function onYes() {
|
||||
void markStepSeen('child-mode-tour-offer')
|
||||
router.push('/child')
|
||||
// Fire the overview step once we're on /child. anchorSelector resolves the anchor.
|
||||
setTimeout(() => maybeShow('child-mode-overview'), 400)
|
||||
}
|
||||
|
||||
function onLater() {
|
||||
dismissedThisSession.value = true
|
||||
}
|
||||
|
||||
function onNoThanks() {
|
||||
void markStepSeen('child-mode-tour-offer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-message {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary, #f3f3f3);
|
||||
color: var(--btn-secondary-text, #666);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--btn-primary-hover, #5a67d8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="step"
|
||||
class="tutorial-root"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="bodyId"
|
||||
>
|
||||
<!-- Spotlight: four dim rectangles forming a hole around the anchor. -->
|
||||
<template v-if="anchorRect">
|
||||
<div class="dim" :style="dimTopStyle" />
|
||||
<div class="dim" :style="dimBottomStyle" />
|
||||
<div class="dim" :style="dimLeftStyle" />
|
||||
<div class="dim" :style="dimRightStyle" />
|
||||
<div class="ring" :style="ringStyle" aria-hidden="true" />
|
||||
<!-- Transparent blocker over the highlighted element so it cannot be
|
||||
clicked/interacted with while this step is active. -->
|
||||
<div class="anchor-blocker" :style="blockerStyle" aria-hidden="true" />
|
||||
</template>
|
||||
<div v-else class="dim dim-full" />
|
||||
|
||||
<!-- Coach mark card -->
|
||||
<div
|
||||
ref="cardEl"
|
||||
class="card"
|
||||
:class="{
|
||||
'card-sheet': isMobile,
|
||||
'card-floating': !isMobile && !!anchorRect,
|
||||
'card-center': !anchorRect && !isMobile,
|
||||
}"
|
||||
:style="cardStyle"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h3 :id="titleId" class="title">{{ step.def.title }}</h3>
|
||||
<p :id="bodyId" class="body" aria-live="polite">{{ step.def.body }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-skip" @click="onSkip" ref="skipBtn">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="onPrimary" ref="primaryBtn">
|
||||
{{ primaryLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { activeStep, dismissActive, skipSession, type ActiveStep } from './controller'
|
||||
|
||||
const MOBILE_BREAKPOINT = 600
|
||||
const CARD_MAX_WIDTH = 320
|
||||
const CARD_MARGIN = 12
|
||||
|
||||
const cardEl = ref<HTMLElement | null>(null)
|
||||
const primaryBtn = ref<HTMLElement | null>(null)
|
||||
const skipBtn = ref<HTMLElement | null>(null)
|
||||
const anchorRect = ref<DOMRect | null>(null)
|
||||
const cardSize = ref<{ width: number; height: number }>({ width: CARD_MAX_WIDTH, height: 160 })
|
||||
const viewport = ref<{ w: number; h: number }>({
|
||||
w: typeof window !== 'undefined' ? window.innerWidth : 1024,
|
||||
h: typeof window !== 'undefined' ? window.innerHeight : 768,
|
||||
})
|
||||
const step = computed<ActiveStep | null>(() => activeStep.value)
|
||||
const titleId = 'tutorial-title'
|
||||
const bodyId = 'tutorial-body'
|
||||
|
||||
const isMobile = computed(() => viewport.value.w <= MOBILE_BREAKPOINT)
|
||||
|
||||
const primaryLabel = computed(() => {
|
||||
const def = step.value?.def
|
||||
if (!def) return 'Continue'
|
||||
if (def.ctaLabel) return def.ctaLabel
|
||||
return 'Continue'
|
||||
})
|
||||
|
||||
function resolveAnchor(): HTMLElement | null {
|
||||
const s = step.value
|
||||
if (!s || !s.anchor) return null
|
||||
return typeof s.anchor === 'function' ? s.anchor() : s.anchor
|
||||
}
|
||||
|
||||
function measureAnchor() {
|
||||
const el = resolveAnchor()
|
||||
if (!el) {
|
||||
anchorRect.value = null
|
||||
return
|
||||
}
|
||||
const rect = el.getBoundingClientRect()
|
||||
anchorRect.value = rect
|
||||
}
|
||||
|
||||
function measureCard() {
|
||||
const el = cardEl.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
cardSize.value = { width: rect.width, height: rect.height }
|
||||
}
|
||||
|
||||
function measureAll() {
|
||||
viewport.value = { w: window.innerWidth, h: window.innerHeight }
|
||||
measureAnchor()
|
||||
measureCard()
|
||||
}
|
||||
|
||||
const dimTopStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '100%',
|
||||
height: `${Math.max(0, r.top - 6)}px`,
|
||||
}
|
||||
})
|
||||
const dimBottomStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${r.bottom + 6}px`,
|
||||
left: '0px',
|
||||
width: '100%',
|
||||
height: `${Math.max(0, viewport.value.h - r.bottom - 6)}px`,
|
||||
}
|
||||
})
|
||||
const dimLeftStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${Math.max(0, r.top - 6)}px`,
|
||||
left: '0px',
|
||||
width: `${Math.max(0, r.left - 6)}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
const dimRightStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${Math.max(0, r.top - 6)}px`,
|
||||
left: `${r.right + 6}px`,
|
||||
width: `${Math.max(0, viewport.value.w - r.right - 6)}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const ringStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${r.top - 6}px`,
|
||||
left: `${r.left - 6}px`,
|
||||
width: `${r.width + 12}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const blockerStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${r.top - 6}px`,
|
||||
left: `${r.left - 6}px`,
|
||||
width: `${r.width + 12}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const cardStyle = computed(() => {
|
||||
if (isMobile.value) return {}
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
const { w: vw, h: vh } = viewport.value
|
||||
const { width: cw, height: ch } = cardSize.value
|
||||
const preferred = step.value?.def.placement ?? 'auto'
|
||||
|
||||
const spaceBelow = vh - r.bottom - CARD_MARGIN
|
||||
const spaceAbove = r.top - CARD_MARGIN
|
||||
const placeBelow =
|
||||
preferred === 'below' ||
|
||||
(preferred !== 'above' && spaceBelow >= ch + CARD_MARGIN) ||
|
||||
spaceAbove < ch + CARD_MARGIN
|
||||
|
||||
const top = placeBelow
|
||||
? Math.min(r.bottom + CARD_MARGIN, vh - ch - CARD_MARGIN)
|
||||
: Math.max(CARD_MARGIN, Math.min(r.top - ch - CARD_MARGIN, vh - ch - CARD_MARGIN))
|
||||
|
||||
const anchorCenterX = r.left + r.width / 2
|
||||
let left = anchorCenterX - cw / 2
|
||||
left = Math.max(CARD_MARGIN, Math.min(left, vw - cw - CARD_MARGIN))
|
||||
|
||||
return { top: `${top}px`, left: `${left}px` }
|
||||
})
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return
|
||||
const focusables = [skipBtn.value, primaryBtn.value].filter(
|
||||
(el): el is HTMLElement => el !== null,
|
||||
)
|
||||
if (focusables.length === 0) return
|
||||
const first = focusables[0]!
|
||||
const last = focusables[focusables.length - 1]!
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onPrimary()
|
||||
return
|
||||
}
|
||||
trapFocus(e)
|
||||
}
|
||||
|
||||
function onPrimary() {
|
||||
dismissActive(true)
|
||||
}
|
||||
|
||||
function onSkip() {
|
||||
skipSession()
|
||||
}
|
||||
|
||||
// Re-measure on scroll/resize while a step is active.
|
||||
function onScroll() {
|
||||
measureAnchor()
|
||||
}
|
||||
function onResize() {
|
||||
measureAll()
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
function startTracking() {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
if (typeof ResizeObserver !== 'undefined' && cardEl.value) {
|
||||
resizeObserver = new ResizeObserver(() => measureCard())
|
||||
resizeObserver.observe(cardEl.value)
|
||||
}
|
||||
// rAF for cases where anchor shifts (animated FAB, etc.)
|
||||
const tick = () => {
|
||||
measureAnchor()
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function stopTracking() {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
watch(
|
||||
step,
|
||||
async (val) => {
|
||||
if (!val) {
|
||||
stopTracking()
|
||||
anchorRect.value = null
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
// Scroll the anchor into view before measuring so the spotlight and card
|
||||
// are positioned correctly. Use 'auto' for synchronous scroll — smooth
|
||||
// scroll races the first measure and can leave the card off-screen.
|
||||
const el = resolveAnchor()
|
||||
if (el) {
|
||||
const pos = getComputedStyle(el).position
|
||||
const rect = el.getBoundingClientRect()
|
||||
const isHidden = rect.width === 0 && rect.height === 0
|
||||
if (isHidden) {
|
||||
anchorRect.value = null
|
||||
} else if (pos !== 'fixed') {
|
||||
el.scrollIntoView({ behavior: 'auto', block: 'center' })
|
||||
}
|
||||
}
|
||||
// Give the browser one frame to finish the synchronous scroll before
|
||||
// measuring anchor and card positions.
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
measureAll()
|
||||
startTracking()
|
||||
// Focus primary button for keyboard users (after paint).
|
||||
await nextTick()
|
||||
primaryBtn.value?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(stopTracking)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dim {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dim-full {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: fixed;
|
||||
border: 3px solid var(--primary, #667eea);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85) inset;
|
||||
pointer-events: none;
|
||||
animation: tutorial-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.anchor-blocker {
|
||||
position: fixed;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
border-radius: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ring {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tutorial-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: fixed;
|
||||
background: var(--form-bg, #fff);
|
||||
color: var(--text-primary, #222);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
pointer-events: auto;
|
||||
max-width: 320px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-floating {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.card-center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.card-sheet {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 1rem 1.1rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--form-heading, #667eea);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-skip {
|
||||
background: var(--btn-secondary, #f3f3f3);
|
||||
color: var(--btn-secondary-text, #666);
|
||||
}
|
||||
.btn-skip:hover {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--btn-primary-hover, #5a67d8);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user