mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 13:56:36 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3721e484 | ||
|
|
5e8ce01c58 | ||
|
|
52946ee0ac | ||
|
|
2976fb9b4d | ||
|
|
41ad9a6df1 | ||
|
|
dcf2f4da06 | ||
|
|
a8bb34ec92 |
No files matched your search
@@ -0,0 +1,86 @@
|
||||
name: Auto Issue Comment
|
||||
|
||||
# Optional: automatically escalate every new issue to Claude for a full
|
||||
# investigation + draft fix. Disabled by default (only workflow_dispatch).
|
||||
# To enable auto-triage on every new issue, uncomment the `issues:` trigger.
|
||||
#
|
||||
# Note: `@claude` mentions only trigger claude.yml when the commenter is a
|
||||
# HUMAN user (or github-actions[bot] via PAT). This workflow posts the
|
||||
# comment as github-actions[bot], so claude.yml must allow that bot.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue number to triage with Claude"
|
||||
required: true
|
||||
type: number
|
||||
# issues:
|
||||
# types: [opened]
|
||||
|
||||
jobs:
|
||||
# Escalate an issue to full Claude implementation by adding the `claude`
|
||||
# label. claude.yml listens on `issues:labeled` + label=='claude' and
|
||||
# triggers Job 2 (claude-response) which does the actual work.
|
||||
trigger-claude:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && !contains(toJSON(github.event.issue.labels), 'claude'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Resolve issue number
|
||||
id: resolve
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.issue_number }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Add claude label + post instructions
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const issueNumber = Number('${{ steps.resolve.outputs.number }}');
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log(`Issue #${issueNumber} already has 'claude' label — skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['claude'],
|
||||
});
|
||||
|
||||
const body = [
|
||||
'🤖 Auto-triaging with Claude.',
|
||||
'',
|
||||
'Claude will:',
|
||||
'',
|
||||
'1. Read this issue and all comments.',
|
||||
'2. Classify and route to the right area (`src/praisonaiui/`, `src/frontend/`, `docs/`, or external repo).',
|
||||
'3. Implement a minimal fix per `AGENTS.md`.',
|
||||
'4. Run `pytest` + the import-time invariant locally.',
|
||||
'5. Open a draft PR linked to this issue.',
|
||||
'',
|
||||
'Follow-ups in this thread with `@claude` will re-trigger the loop.'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
+225
-27
@@ -1,8 +1,26 @@
|
||||
name: Claude Assistant
|
||||
|
||||
# Triggers:
|
||||
# - issues:opened → lightweight triage (welcome comment + label)
|
||||
# - issues:labeled → full implementation when `claude` label applied
|
||||
# - issues:assigned → full implementation when issue assigned to Claude
|
||||
# - issue_comment → full implementation when @claude mentioned (issues + PRs)
|
||||
# - pull_request_review → full implementation when @claude mentioned in review
|
||||
# - pull_request_review_comment → full implementation when @claude mentioned inline
|
||||
#
|
||||
# Focus: CLAUDE only. Copilot and CodeRabbit are GitHub Apps that react to PRs
|
||||
# automatically — no workflow file needed. auto-pr-comment.yml orchestrates the
|
||||
# CodeRabbit → Copilot → Claude review chain for PRs.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, labeled, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -12,15 +30,74 @@ permissions:
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# Job 1 — Issue triage (runs on ANY new issue, including from external users)
|
||||
# Posts a welcome comment. Does NOT execute Claude Code.
|
||||
# To escalate to full implementation, a maintainer adds the `claude` label
|
||||
# or mentions @claude in a comment — which triggers Job 2.
|
||||
# ================================================================
|
||||
issue-triage:
|
||||
if: |
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'opened' &&
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const body = [
|
||||
'👋 Thanks for opening an issue in **PraisonAIUI**.',
|
||||
'',
|
||||
'A maintainer will triage this shortly. If you would like Claude to investigate and draft a fix, a maintainer can:',
|
||||
'',
|
||||
'- Apply the `claude` label to this issue, **or**',
|
||||
'- Mention `@claude` in a comment with any additional instructions.',
|
||||
'',
|
||||
'Please make sure your issue includes:',
|
||||
'',
|
||||
'1. A clear description of the behaviour you expect vs what you see.',
|
||||
'2. Minimal reproduction steps (code snippet or `aiui.template.yaml`).',
|
||||
'3. Version: `python -c "import praisonaiui; print(praisonaiui.__version__)"`.',
|
||||
'4. Any relevant log output.',
|
||||
'',
|
||||
'See [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions.'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Job 2 — Full Claude Code implementation
|
||||
# Runs when:
|
||||
# - A comment containing `@claude` is posted on an issue or PR
|
||||
# - The `claude` label is added to an issue
|
||||
# - The issue is assigned to a Claude bot
|
||||
# - A PR review or review-comment contains `@claude`
|
||||
# ================================================================
|
||||
claude-response:
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number }}
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]'
|
||||
github.actor != 'renovate[bot]' &&
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && contains(toJSON(github.event.label), 'claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -42,46 +119,167 @@ jobs:
|
||||
allowed_bots: "praisonai-triage-agent[bot]"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
You are implementing changes in MervinPraison/PraisonAIUI (Server-Driven UI). Follow AGENTS.md in this repository.
|
||||
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
|
||||
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
|
||||
|
||||
Primary scope: src/praisonaiui/, tests/, docs/. If the fix belongs in praisonaiagents, praisonai, or PraisonAI-Tools, route there and explain — do not implement SDK/tool code inside this repo unless it is AIUI integration only.
|
||||
|
||||
STEP 0 — GIT & GITHUB CLI:
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 0 — GIT & GITHUB CLI
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
gh auth setup-git
|
||||
|
||||
STEP 1 — ISSUE:
|
||||
Read the issue and all comments. Implement what the issue describes with minimal, focused changes.
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 1 — READ GUIDELINES
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Read `AGENTS.md` and any `docs/principles.md`. Key invariants:
|
||||
• Cold import of `praisonaiui` must stay under 200 ms; module count must not grow.
|
||||
• Heavy / optional deps (`mcp`, `langchain`, `llama_index`, `semantic_kernel`,
|
||||
`slack_sdk`, `discord.py`, `botbuilder.core`, `openai`, `anthropic`, `mistralai`,
|
||||
`google.generativeai`) must be **lazy-imported** — never eager.
|
||||
• Public API is verb-first. New classes in `src/praisonaiui/message.py` ending
|
||||
in `Message` with a verb prefix (Ask*, Do*, Request*, …) are forbidden —
|
||||
use a module-level `async def` verb helper instead (see `prompt`, `error`).
|
||||
• Deprecations must emit a runtime `warnings.warn(..., DeprecationWarning, stacklevel=2)`
|
||||
on first access, not only in the docstring.
|
||||
• Deterministic serialisation in anything reaching the wire (sorted keys, stable field
|
||||
order). See existing `to_dict` patterns.
|
||||
• Multi-agent + async safe: no global mutable state without a lock.
|
||||
|
||||
STEP 2 — BRANCH (single name; reuse in STEP 4):
|
||||
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before any code)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Answer these questions inline in your response, then proceed only if you pass:
|
||||
|
||||
STEP 3 — TEST / VERIFY:
|
||||
Run targeted pytest or smoke checks relevant to your edits (e.g. pytest tests/unit -q).
|
||||
ROUTING:
|
||||
1. **Core aiui** (`src/praisonaiui/`) — protocols, message types, server, features.
|
||||
2. **Features** (`src/praisonaiui/features/`) — opt-in capabilities (MCP, platforms, lifecycle, audio, auth, sharing, etc.).
|
||||
3. **UI builders** (`src/praisonaiui/ui.py`) — declarative component helpers.
|
||||
4. **Frontend** (`src/frontend/`) — React components / stores / SSE handlers.
|
||||
5. **Tests** (`tests/unit/`, `tests/integration/`) — new features require both smoke and real-agentic tests.
|
||||
6. **Docs** (`docs/`) — canonical name per feature, migration table for renames.
|
||||
|
||||
STEP 4 — COMMIT, PUSH, OPEN PR (mandatory if you produced commits):
|
||||
git add -A
|
||||
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
|
||||
git push -u origin "$BRANCH"
|
||||
REPO="${{ github.repository }}"
|
||||
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
|
||||
else
|
||||
gh pr create --repo "$REPO" --base main --head "$BRANCH" --title "feat: <short title>" --body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}"
|
||||
fi
|
||||
CRITICAL: Do not stop after `git push`. You MUST complete `gh pr create` (or print the existing PR URL from the step above) and post that URL on the issue. Never ask a human to open the PR manually.
|
||||
EXTERNAL REPOS (do NOT edit from within this repo — route the work to the right place and stop):
|
||||
• Core SDK: `MervinPraison/PraisonAI` (`praisonaiagents`, `praisonai`) — agent runtime.
|
||||
• Tools: `MervinPraison/PraisonAI-Tools` — tool implementations.
|
||||
• Docs site: `MervinPraison/PraisonAIDocs` — product docs.
|
||||
If the requested change belongs in an external repo, comment on the issue explaining
|
||||
where it should live and DO NOT open a PR in this repo.
|
||||
|
||||
CONCEPTUAL CHECKS (if ANY fails — add a comment, do NOT open a PR):
|
||||
• Does it duplicate an existing public symbol in `src/praisonaiui/__init__.py`?
|
||||
• Does it add new class-first message types (`class Ask*Message`, `class *Message`
|
||||
with a verb in the name)? → Use a verb-first helper instead.
|
||||
• Does it add a new hard dependency to `pyproject.toml` `[project] dependencies`?
|
||||
→ Move to `[project.optional-dependencies]` and wrap imports lazily.
|
||||
• Does it break backward compat (rename, signature change, removal)? → Add
|
||||
a deprecation alias that emits `DeprecationWarning`; never hard-remove before the
|
||||
documented removal version (e.g. `0.5.0`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 3 — IMPLEMENT
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Single branch for the whole flow (reuse the same name in STEP 5):
|
||||
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
Make minimal, focused changes:
|
||||
• Stay within the files listed in the issue's "Scope" / "Files to change" section.
|
||||
• Prefer extending existing modules over adding new ones.
|
||||
• No emojis, no comments, no markdown except where the user asked for it.
|
||||
• Python ≥ 3.10 style (`list[...]` not `List[...]`, `X | None` not `Optional[X]`).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 4 — TEST / VERIFY
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Run these targeted checks (NOT the full suite):
|
||||
|
||||
# 1. Lint on only the files you changed:
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '\.py$' | tr '\n' ' ')
|
||||
[ -n "$CHANGED" ] && ruff check $CHANGED
|
||||
|
||||
# 2. Unit tests for the module(s) you touched:
|
||||
pytest tests/unit/<your_new_test_file>.py -v --tb=short
|
||||
|
||||
# 3. Import-time invariant (MUST stay under 200 ms cold):
|
||||
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
|
||||
|
||||
# 4. Real agentic end-to-end test (write this into tests/integration/
|
||||
# if your feature changes the agent-facing API):
|
||||
# from praisonaiagents import Agent
|
||||
# import praisonaiui as aiui
|
||||
# agent = Agent(name="demo", instructions="You are a helpful assistant")
|
||||
# result = agent.start("Say hello in one sentence")
|
||||
# print(result)
|
||||
|
||||
If anything fails, fix the root cause (never skip, xfail, or delete the test).
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 5 — COMMIT, PUSH, OPEN PR (MANDATORY if you produced commits)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
git add -A
|
||||
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
|
||||
git push -u origin "$BRANCH"
|
||||
REPO="${{ github.repository }}"
|
||||
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
|
||||
else
|
||||
gh pr create --repo "$REPO" --base main --head "$BRANCH" \
|
||||
--title "feat: <short title>" \
|
||||
--body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}
|
||||
|
||||
## Summary
|
||||
|
||||
<what changed in one paragraph>
|
||||
|
||||
## Before / After
|
||||
|
||||
<copy-pasteable snippet per public API change>
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<copy from the issue; tick boxes only with commit SHA + file path evidence>
|
||||
|
||||
## Test evidence
|
||||
|
||||
\`\`\`
|
||||
<paste pytest output>
|
||||
\`\`\`
|
||||
|
||||
## Import-time proof
|
||||
|
||||
\`\`\`
|
||||
<paste the python -c import-time output>
|
||||
\`\`\`
|
||||
"
|
||||
fi
|
||||
|
||||
CRITICAL: Do NOT stop after `git push`. You MUST complete `gh pr create` (or print the
|
||||
existing PR URL from the step above) and post that URL on the issue. Never ask a
|
||||
human to open the PR manually.
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
STEP 6 — RESPOND TO REVIEW FEEDBACK (if this run was triggered by a review comment)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
If you were invoked by a PR review comment, you are the Lead Engineer for this PR:
|
||||
• Read ALL reviewer comments in chronological order (CodeRabbit → Copilot → human).
|
||||
• Fix every VALID issue by pushing commits to THIS branch (do NOT open a new PR).
|
||||
• Reply with a summary of which comments you addressed, which you disagreed with (and why).
|
||||
• Never ship a fix that removes/weakens a test.
|
||||
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(ruff:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
Bash(npx:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
Bash(python -m ruff:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.110"
|
||||
version = "0.3.112"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -46,15 +46,25 @@ def __getattr__(name: str):
|
||||
_lifecycle_attrs = {
|
||||
"on_app_startup",
|
||||
"on_app_shutdown",
|
||||
# PraisonAIUI-native aliases
|
||||
"on_startup",
|
||||
"on_shutdown",
|
||||
}
|
||||
_window_message_attrs = {
|
||||
"on_window_message",
|
||||
"send_window_message",
|
||||
# PraisonAIUI-native aliases
|
||||
"on_parent_message",
|
||||
"send_to_parent",
|
||||
}
|
||||
_audio_attrs = {
|
||||
"on_audio_start",
|
||||
"on_audio_chunk",
|
||||
"on_audio_end",
|
||||
# PraisonAIUI-native aliases
|
||||
"on_mic_start",
|
||||
"on_mic_data",
|
||||
"on_mic_stop",
|
||||
}
|
||||
_message_attrs = {
|
||||
"Message",
|
||||
@@ -68,6 +78,11 @@ def __getattr__(name: str):
|
||||
"AskActionMessage",
|
||||
"AskElementMessage",
|
||||
"ErrorMessage",
|
||||
# PraisonAIUI-native aliases (noun-first, API-direction compliant)
|
||||
"TextPrompt",
|
||||
"FilePrompt",
|
||||
"ChoicePrompt",
|
||||
"LocationPrompt",
|
||||
}
|
||||
_sync_attrs = {"make_async", "run_sync", "AsyncContext"}
|
||||
_utils_attrs = {"sleep", "format_duration", "truncate_text", "safe_filename"}
|
||||
@@ -92,6 +107,14 @@ def __getattr__(name: str):
|
||||
"get_copilot_functions",
|
||||
"get_copilot_function",
|
||||
"call_copilot_function",
|
||||
# PraisonAIUI-native aliases
|
||||
"UIFunction",
|
||||
"UIFunctionParameter",
|
||||
"ui_function",
|
||||
"on_ui_function",
|
||||
"call_ui_function",
|
||||
"get_ui_function",
|
||||
"get_ui_functions",
|
||||
}
|
||||
_chat_settings_attrs = {
|
||||
"ChatSettings",
|
||||
@@ -105,6 +128,9 @@ def __getattr__(name: str):
|
||||
"trigger_settings_update",
|
||||
"create_model_settings",
|
||||
"create_ui_settings",
|
||||
# PraisonAIUI-native aliases
|
||||
"Settings",
|
||||
"on_settings_change",
|
||||
}
|
||||
_mcp_attrs = {
|
||||
"MCPServer",
|
||||
@@ -115,6 +141,8 @@ def __getattr__(name: str):
|
||||
"current_channel",
|
||||
"current_user",
|
||||
"on_slack_reaction_added",
|
||||
# PraisonAIUI-native alias
|
||||
"on_slack_reaction",
|
||||
}
|
||||
_auth_attrs = {
|
||||
"oauth_callback",
|
||||
@@ -124,6 +152,11 @@ def __getattr__(name: str):
|
||||
"User",
|
||||
"Session",
|
||||
"on_shared_thread_view",
|
||||
# PraisonAIUI-native aliases
|
||||
"on_oauth_login",
|
||||
"on_header_login",
|
||||
"on_password_login",
|
||||
"on_shared_view",
|
||||
}
|
||||
_usage_attrs = {"get_token_usage"}
|
||||
_instrumentation_attrs = {
|
||||
@@ -278,7 +311,7 @@ def __getattr__(name: str):
|
||||
|
||||
return getattr(platform_adapters, name)
|
||||
if name in _auth_attrs:
|
||||
if name == "on_shared_thread_view":
|
||||
if name in ("on_shared_thread_view", "on_shared_view"):
|
||||
from praisonaiui.features import sharing
|
||||
|
||||
return getattr(sharing, name)
|
||||
@@ -523,6 +556,35 @@ __all__ = [
|
||||
"no_instrument",
|
||||
# Usage tracking
|
||||
"get_token_usage",
|
||||
# ── PraisonAIUI-native, non-developer-friendly names ────────────
|
||||
# Shorter, plain-English aliases for the symbols above. Use these
|
||||
# in new code; the original names remain importable for backward
|
||||
# compatibility.
|
||||
"TextPrompt",
|
||||
"FilePrompt",
|
||||
"ChoicePrompt",
|
||||
"LocationPrompt",
|
||||
"UIFunction",
|
||||
"UIFunctionParameter",
|
||||
"ui_function",
|
||||
"on_ui_function",
|
||||
"call_ui_function",
|
||||
"get_ui_function",
|
||||
"get_ui_functions",
|
||||
"Settings",
|
||||
"on_settings_change",
|
||||
"on_oauth_login",
|
||||
"on_header_login",
|
||||
"on_password_login",
|
||||
"on_shared_view",
|
||||
"on_slack_reaction",
|
||||
"on_startup",
|
||||
"on_shutdown",
|
||||
"on_parent_message",
|
||||
"send_to_parent",
|
||||
"on_mic_start",
|
||||
"on_mic_data",
|
||||
"on_mic_stop",
|
||||
# Feature protocol
|
||||
"BaseFeatureProtocol",
|
||||
"register_feature",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.110"
|
||||
__version__ = "0.3.112"
|
||||
+127
-2
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
from typing import Any, Callable, Iterable, Optional, Protocol
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
|
||||
# User storage (in-memory for now, can be replaced with database)
|
||||
_users: dict[str, dict[str, Any]] = {}
|
||||
@@ -526,3 +527,127 @@ async def enhanced_logout_handler(request: Request) -> JSONResponse:
|
||||
revoke_token(token)
|
||||
|
||||
return JSONResponse({"status": "logged_out"})
|
||||
|
||||
|
||||
def _constant_time_eq(a: str, b: str) -> bool:
|
||||
"""Compare two strings in constant time to prevent timing attacks."""
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
|
||||
|
||||
_LOGIN_FORM_HTML = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Required</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 400px; margin: 100px auto; padding: 20px; }
|
||||
.form { background: #f5f5f5; padding: 20px; border-radius: 8px; }
|
||||
input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px; }
|
||||
button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="form">
|
||||
<h2>Authentication Required</h2>
|
||||
<form method="get">
|
||||
<input type="password" name="token" placeholder="Enter access token" required>
|
||||
<button type="submit">Access Dashboard</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class TokenQueryMiddleware:
|
||||
"""Authenticates requests that carry ?token=<SECRET> in the URL or
|
||||
Authorization: Bearer <SECRET> in the header.
|
||||
|
||||
On first hit, the query-param token is validated; the middleware sets a
|
||||
session cookie (httpOnly, SameSite=Lax, Secure when https) so the token
|
||||
doesn't need to be re-sent on every asset request, and emits a redirect
|
||||
that strips ?token from window.location. This matches Jupyter's model.
|
||||
"""
|
||||
|
||||
EXEMPT_PATHS = {"/health", "/api/health"}
|
||||
COOKIE_NAME = "praisonaiui_token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
*,
|
||||
expected_token: str,
|
||||
exempt_paths: Optional[Iterable[str]] = None
|
||||
):
|
||||
self.app = app
|
||||
self.expected_token = expected_token
|
||||
self.exempt = set(self.EXEMPT_PATHS) | set(exempt_paths or ())
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http" or not self.expected_token:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
if any(path.startswith(p) for p in self.exempt):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
|
||||
# 1. Already authed via cookie?
|
||||
if _constant_time_eq(
|
||||
request.cookies.get(self.COOKIE_NAME, ""),
|
||||
self.expected_token,
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# 2. Bearer header?
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.startswith("Bearer ") and _constant_time_eq(
|
||||
auth[7:], self.expected_token
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# 3. ?token=… query param? → set cookie, redirect to same URL
|
||||
# minus the token so it doesn't leak via Referer / history.
|
||||
qs_token = request.query_params.get("token", "")
|
||||
if qs_token and _constant_time_eq(qs_token, self.expected_token):
|
||||
clean_qs = {k: v for k, v in request.query_params.items()
|
||||
if k != "token"}
|
||||
clean_url = str(request.url.replace_query_params(**clean_qs))
|
||||
response = RedirectResponse(clean_url, status_code=303)
|
||||
response.set_cookie(
|
||||
self.COOKIE_NAME,
|
||||
self.expected_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=request.url.scheme == "https",
|
||||
max_age=60 * 60 * 24, # 24h
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
# 4. No auth → 401 JSON for /api/*, minimal login form for HTML.
|
||||
if path.startswith("/api/"):
|
||||
response = JSONResponse(
|
||||
{"error": "Unauthorized",
|
||||
"hint": "Append ?token=<SECRET> to the URL or send "
|
||||
"Authorization: Bearer <SECRET>."},
|
||||
status_code=401,
|
||||
)
|
||||
else:
|
||||
response = HTMLResponse(_LOGIN_FORM_HTML, status_code=401)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# "on_*" verb form reads as "do this when …" which is far clearer to
|
||||
# non-developers than the "*_callback" suffix. Original names remain
|
||||
# importable for backward compatibility.
|
||||
|
||||
on_oauth_login = oauth_callback
|
||||
on_header_login = header_auth_callback
|
||||
on_password_login = password_auth_callback
|
||||
@@ -296,3 +296,16 @@ def create_ui_settings() -> ChatSettings:
|
||||
title="UI Settings",
|
||||
description="Configure the user interface",
|
||||
)
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# Plain-English names. ``Settings`` is easier for non-developers than
|
||||
# ``ChatSettings`` (a settings panel is just a settings panel). The
|
||||
# "change" verb reads more naturally than "update" for UI form events.
|
||||
|
||||
Settings = ChatSettings
|
||||
|
||||
|
||||
def on_settings_change(func):
|
||||
"""Alias for :func:`on_settings_update` using plain-English verb."""
|
||||
return on_settings_update(func)
|
||||
@@ -250,3 +250,17 @@ async def show_notification(
|
||||
) -> Dict[str, Any]:
|
||||
"""Show a notification (implemented in frontend)."""
|
||||
return {"action": "show_notification", "message": message, "type": type, "duration": duration}
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# Non-developer-friendly names. The "UI function" phrasing communicates
|
||||
# "a function the UI can call" without borrowing external product vocabulary.
|
||||
# The old names above remain importable for backward compatibility.
|
||||
|
||||
UIFunction = CopilotFunction
|
||||
UIFunctionParameter = CopilotFunctionParameter
|
||||
ui_function = copilot_function
|
||||
on_ui_function = on_copilot_function_call
|
||||
call_ui_function = call_copilot_function
|
||||
get_ui_function = get_copilot_function
|
||||
get_ui_functions = get_copilot_functions
|
||||
@@ -417,3 +417,12 @@ def on_audio_end(func: Callable) -> Callable:
|
||||
await handler(aiui.InboundMessage(content=transcript, source="voice"))
|
||||
"""
|
||||
return register_audio_end_hook(func)
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# "mic" is what non-developers call the microphone; "audio" is correct
|
||||
# but more abstract. "data" is friendlier than "chunk" for a lay reader.
|
||||
|
||||
on_mic_start = on_audio_start
|
||||
on_mic_data = on_audio_chunk
|
||||
on_mic_stop = on_audio_end
|
||||
@@ -283,3 +283,11 @@ def on_app_shutdown(func: Callable) -> Callable:
|
||||
await vector_store.close()
|
||||
"""
|
||||
return register_shutdown_hook(func)
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# Drop the "app_" prefix — it's implied by context (you're obviously
|
||||
# registering hooks on *this* app).
|
||||
|
||||
on_startup = on_app_startup
|
||||
on_shutdown = on_app_shutdown
|
||||
@@ -23,12 +23,13 @@ __all__ = [
|
||||
"current_user",
|
||||
"channel_context",
|
||||
"on_slack_reaction_added",
|
||||
"on_slack_reaction", # PraisonAIUI-native alias (shorter, plain-English)
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import platform-specific functionality."""
|
||||
if name == "on_slack_reaction_added":
|
||||
if name in ("on_slack_reaction_added", "on_slack_reaction"):
|
||||
from .slack import on_slack_reaction_added
|
||||
|
||||
return on_slack_reaction_added
|
||||
|
||||
@@ -159,3 +159,8 @@ def get_share_url(token: str, base_url: str = "") -> str:
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
return f"{base_url}/shared/{token}"
|
||||
|
||||
|
||||
# ── PraisonAIUI-native alias ──────────────────────────────────────
|
||||
# Shorter, plainer-English verb form.
|
||||
on_shared_view = on_shared_thread_view
|
||||
@@ -352,3 +352,11 @@ def on_window_message(source: Optional[str] = None) -> Callable:
|
||||
return register_window_message_hook(source, func)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# "parent" is what non-developers call the page that embeds the chat.
|
||||
# "window" is technical jargon from the browser postMessage API.
|
||||
|
||||
on_parent_message = on_window_message
|
||||
send_to_parent = send_window_message
|
||||
@@ -930,3 +930,16 @@ class AskElementMessage:
|
||||
return None
|
||||
finally:
|
||||
self._context._pending_asks.pop(self._id, None)
|
||||
|
||||
|
||||
# ── PraisonAIUI-native aliases ──────────────────────────────────────
|
||||
# Noun-first, plain-English names that pair with the verb-first
|
||||
# ``aiui.prompt()`` helper (see issue #41 API-direction policy).
|
||||
# A non-developer reads ``FilePrompt`` as "a prompt that asks for a
|
||||
# file" — no "Message" suffix convention required. The original
|
||||
# ``Ask*Message`` names remain importable for backward compatibility.
|
||||
|
||||
TextPrompt = AskUserMessage
|
||||
FilePrompt = AskFileMessage
|
||||
ChoicePrompt = AskActionMessage
|
||||
LocationPrompt = AskElementMessage
|
||||
@@ -388,11 +388,15 @@ class AuthConfig(BaseModel):
|
||||
"""Authentication configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
providers: list[Literal["password", "google", "github", "azure", "auth0"]] = Field(
|
||||
providers: list[Literal["password", "google", "github", "azure", "auth0", "url_token"]] = Field(
|
||||
default_factory=lambda: ["password"]
|
||||
)
|
||||
oauth: list[AuthProviderConfig] = Field(default_factory=list)
|
||||
require_auth: bool = Field(default=False, alias="requireAuth")
|
||||
url_token: Optional[str] = Field(default=None, alias="urlToken")
|
||||
url_token_env: Optional[str] = Field(
|
||||
default="AIUI_URL_TOKEN", alias="urlTokenEnv"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
@@ -2401,6 +2401,15 @@ def create_app(
|
||||
# Add optional AUTH_ENFORCE middleware (env var driven)
|
||||
middleware.append(Middleware(AuthEnforcementMiddleware))
|
||||
|
||||
# URL-token auth — opt-in via env. Enables query-param / cookie auth so
|
||||
# dashboards can be shared by URL (Jupyter model).
|
||||
_tok = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
|
||||
if _tok:
|
||||
from praisonaiui.auth import TokenQueryMiddleware
|
||||
middleware.append(
|
||||
Middleware(TokenQueryMiddleware, expected_token=_tok)
|
||||
)
|
||||
|
||||
if require_auth:
|
||||
middleware.append(
|
||||
Middleware(
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Test API direction constraints to prevent regression.
|
||||
|
||||
This test file enforces the API naming conventions established in issue #41.
|
||||
It fails CI if new symbols are added that violate the verb-first naming pattern.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Allow-lists for current symbols that should not be changed
|
||||
# Modifying these lists should be a deliberate decision requiring review
|
||||
|
||||
# Current Ask*Message classes that are documented but deprecated
|
||||
ALLOWED_ASK_MESSAGE_CLASSES = {
|
||||
"AskUserMessage",
|
||||
"AskFileMessage",
|
||||
"AskActionMessage",
|
||||
"AskElementMessage",
|
||||
}
|
||||
|
||||
# Verbs that should not be used to start Message class names
|
||||
FORBIDDEN_VERB_PREFIXES = {
|
||||
"Ask",
|
||||
"Do",
|
||||
"Request",
|
||||
"Show",
|
||||
"Get",
|
||||
"Set",
|
||||
"Send",
|
||||
"Create",
|
||||
"Make",
|
||||
"Build",
|
||||
"Generate",
|
||||
}
|
||||
|
||||
# Current callback decorators in callbacks.py that are allowed
|
||||
ALLOWED_CALLBACK_DECORATORS = {
|
||||
"welcome",
|
||||
"reply",
|
||||
"goodbye",
|
||||
"cancel",
|
||||
"button",
|
||||
"login",
|
||||
"settings",
|
||||
"profiles",
|
||||
"starters",
|
||||
"on",
|
||||
"page",
|
||||
"resume",
|
||||
}
|
||||
|
||||
# Future-proof decorator namespaces that are always allowed
|
||||
ALLOWED_DECORATOR_NAMESPACES = {
|
||||
"on_", # e.g. on_connect, on_message
|
||||
"get_", # e.g. get_starters, get_chat_profiles
|
||||
}
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project root directory."""
|
||||
return Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def extract_classes_from_file(file_path: Path) -> list[str]:
|
||||
"""Extract class names from a Python file."""
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
classes = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
classes.append(node.name)
|
||||
return classes
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
# Skip files that can't be parsed
|
||||
return []
|
||||
|
||||
|
||||
def extract_functions_from_file(file_path: Path) -> list[str]:
|
||||
"""Extract top-level function names from a Python file."""
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
functions = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
|
||||
functions.append(node.name)
|
||||
return functions
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
# Skip files that can't be parsed
|
||||
return []
|
||||
|
||||
|
||||
def get_exported_symbols_from_init() -> set[str]:
|
||||
"""Get symbols exported from __init__.py's __all__ list."""
|
||||
init_path = get_project_root() / "src" / "praisonaiui" / "__init__.py"
|
||||
if not init_path.exists():
|
||||
return set()
|
||||
|
||||
try:
|
||||
with open(init_path, "r", encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "__all__":
|
||||
if isinstance(node.value, ast.List):
|
||||
return {
|
||||
elt.value
|
||||
for elt in node.value.elts
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
|
||||
}
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return set()
|
||||
|
||||
|
||||
class TestAskMessageRegression:
|
||||
"""Test that no new Ask*Message classes are added."""
|
||||
|
||||
def test_no_new_ask_message_classes_in_message_py(self):
|
||||
"""Fail if new Ask*Message classes are added to message.py."""
|
||||
message_file = get_project_root() / "src" / "praisonaiui" / "message.py"
|
||||
classes = extract_classes_from_file(message_file)
|
||||
|
||||
ask_message_classes = {cls for cls in classes if re.match(r"^Ask.+Message$", cls)}
|
||||
|
||||
new_ask_classes = ask_message_classes - ALLOWED_ASK_MESSAGE_CLASSES
|
||||
|
||||
assert not new_ask_classes, (
|
||||
f"New Ask*Message classes detected in message.py: {new_ask_classes}. "
|
||||
f"The API direction is verb-first helpers, not class-first patterns. "
|
||||
f"Add a verb-first helper function instead (e.g., request_file, prompt). "
|
||||
f"See issue #41 Phase 2 for examples."
|
||||
)
|
||||
|
||||
def test_no_new_ask_message_classes_in_exports(self):
|
||||
"""Fail if new Ask*Message classes are exported from __init__.py."""
|
||||
exported_symbols = get_exported_symbols_from_init()
|
||||
|
||||
exported_ask_classes = {
|
||||
symbol for symbol in exported_symbols if re.match(r"^Ask.+Message$", symbol)
|
||||
}
|
||||
|
||||
new_exported_ask_classes = exported_ask_classes - ALLOWED_ASK_MESSAGE_CLASSES
|
||||
|
||||
assert not new_exported_ask_classes, (
|
||||
f"New Ask*Message classes exported from __init__.py: {new_exported_ask_classes}. "
|
||||
f"Remove from __all__ or add equivalent verb-first helper instead."
|
||||
)
|
||||
|
||||
|
||||
class TestVerbPrefixedMessageClasses:
|
||||
"""Test that no new Message classes start with forbidden verbs."""
|
||||
|
||||
def test_no_verb_prefixed_message_classes(self):
|
||||
"""Fail if Message classes are added with verb prefixes."""
|
||||
message_file = get_project_root() / "src" / "praisonaiui" / "message.py"
|
||||
classes = extract_classes_from_file(message_file)
|
||||
|
||||
# Find classes ending in 'Message' that start with forbidden verbs
|
||||
forbidden_classes = []
|
||||
for cls in classes:
|
||||
if cls.endswith("Message"):
|
||||
for verb in FORBIDDEN_VERB_PREFIXES:
|
||||
if cls.startswith(verb) and cls != "Message":
|
||||
# Allow existing Ask*Message classes
|
||||
if cls not in ALLOWED_ASK_MESSAGE_CLASSES:
|
||||
forbidden_classes.append(cls)
|
||||
break
|
||||
|
||||
assert not forbidden_classes, (
|
||||
f"Message classes with forbidden verb prefixes detected: {forbidden_classes}. "
|
||||
f"Forbidden prefixes: {sorted(FORBIDDEN_VERB_PREFIXES)}. "
|
||||
f"Use verb-first module functions instead of verb-in-class-name pattern."
|
||||
)
|
||||
|
||||
|
||||
class TestCallbackDecoratorRegression:
|
||||
"""Test callback decorator naming constraints."""
|
||||
|
||||
def test_no_unauthorized_callback_decorators(self):
|
||||
"""Fail if new decorators are added that don't follow on_*/get_* pattern."""
|
||||
callbacks_file = get_project_root() / "src" / "praisonaiui" / "callbacks.py"
|
||||
functions = extract_functions_from_file(callbacks_file)
|
||||
|
||||
# Filter to decorator-like functions (those that likely return callables)
|
||||
# This is a heuristic but should catch most decorator functions
|
||||
unauthorized_decorators = []
|
||||
|
||||
for func in functions:
|
||||
# Skip if it's already in the allow-list
|
||||
if func in ALLOWED_CALLBACK_DECORATORS:
|
||||
continue
|
||||
|
||||
# Skip if it matches an allowed namespace
|
||||
if any(func.startswith(namespace) for namespace in ALLOWED_DECORATOR_NAMESPACES):
|
||||
continue
|
||||
|
||||
# If we get here, it's potentially unauthorized
|
||||
unauthorized_decorators.append(func)
|
||||
|
||||
assert not unauthorized_decorators, (
|
||||
f"New callback decorators detected that don't follow naming pattern: {unauthorized_decorators}. "
|
||||
f"New decorators should use 'on_*' or 'get_*' prefixes for framework-neutral vocabulary. "
|
||||
f"Allowed existing decorators: {sorted(ALLOWED_CALLBACK_DECORATORS)}. "
|
||||
f"Allowed new patterns: functions starting with {sorted(ALLOWED_DECORATOR_NAMESPACES)}."
|
||||
)
|
||||
|
||||
def test_callback_decorators_exported_consistently(self):
|
||||
"""Ensure callback decorators are properly exported."""
|
||||
callbacks_file = get_project_root() / "src" / "praisonaiui" / "callbacks.py"
|
||||
functions = extract_functions_from_file(callbacks_file)
|
||||
exported_symbols = get_exported_symbols_from_init()
|
||||
|
||||
# Check that all allowed decorators are exported if they exist
|
||||
missing_exports = []
|
||||
for func in functions:
|
||||
if func in ALLOWED_CALLBACK_DECORATORS and func not in exported_symbols:
|
||||
missing_exports.append(func)
|
||||
|
||||
assert not missing_exports, (
|
||||
f"Callback decorators exist but are not exported: {missing_exports}. "
|
||||
f"Add them to __all__ in __init__.py or remove from callbacks.py."
|
||||
)
|
||||
|
||||
|
||||
class TestAPIDirectionCompliance:
|
||||
"""Test overall API direction compliance."""
|
||||
|
||||
def test_no_verb_in_class_name_pattern_in_exports(self):
|
||||
"""Fail if exported classes follow the verb-in-class-name anti-pattern."""
|
||||
exported_symbols = get_exported_symbols_from_init()
|
||||
|
||||
# Look for class-like symbols (PascalCase) that start with verbs
|
||||
verb_class_pattern = re.compile(
|
||||
r"^(Ask|Do|Request|Show|Get|Set|Send|Create|Make|Build|Generate)[A-Z]"
|
||||
)
|
||||
|
||||
violations = []
|
||||
for symbol in exported_symbols:
|
||||
if verb_class_pattern.match(symbol):
|
||||
# Allow existing Ask*Message classes during transition
|
||||
if symbol not in ALLOWED_ASK_MESSAGE_CLASSES:
|
||||
violations.append(symbol)
|
||||
|
||||
assert not violations, (
|
||||
f"Exported symbols follow verb-in-class-name anti-pattern: {violations}. "
|
||||
f"Use verb-first module functions instead. "
|
||||
f"Example: prefer 'request_file()' over 'RequestFileMessage().send()'."
|
||||
)
|
||||
|
||||
def test_existing_ask_classes_are_documented_as_deprecated(self):
|
||||
"""Ensure existing Ask*Message classes are properly marked for deprecation."""
|
||||
# This test documents the current state and will be updated in future phases
|
||||
message_file = get_project_root() / "src" / "praisonaiui" / "message.py"
|
||||
|
||||
assert message_file.exists(), "message.py should exist"
|
||||
|
||||
with open(message_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Verify the known deprecated classes exist
|
||||
for cls in ALLOWED_ASK_MESSAGE_CLASSES:
|
||||
assert f"class {cls}" in content, f"{cls} should exist in message.py"
|
||||
|
||||
# This test will be enhanced in Phase 1 to check for actual DeprecationWarning calls
|
||||
# For now, it just documents the current state
|
||||
assert True, "Existing Ask*Message classes documented"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+122
-124
@@ -1,64 +1,62 @@
|
||||
"""Tests for audio hooks feature."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features.audio import (
|
||||
AudioFeature,
|
||||
on_audio_start,
|
||||
on_audio_chunk,
|
||||
on_audio_end,
|
||||
register_audio_start_hook,
|
||||
register_audio_chunk_hook,
|
||||
register_audio_end_hook,
|
||||
reset_audio_state,
|
||||
_audio_start_hooks,
|
||||
_audio_chunk_hooks,
|
||||
_audio_end_hooks,
|
||||
_audio_sessions,
|
||||
_audio_start_hooks,
|
||||
_audio_stats,
|
||||
on_audio_chunk,
|
||||
on_audio_end,
|
||||
on_audio_start,
|
||||
register_audio_chunk_hook,
|
||||
register_audio_end_hook,
|
||||
register_audio_start_hook,
|
||||
reset_audio_state,
|
||||
)
|
||||
|
||||
|
||||
class TestAudioFeature:
|
||||
"""Test AudioFeature protocol implementation."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
def test_feature_protocol(self):
|
||||
"""Test that AudioFeature implements BaseFeatureProtocol correctly."""
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
assert feature.name == "audio"
|
||||
assert feature.description == "Streaming audio input hooks for STT"
|
||||
assert len(feature.routes()) == 4
|
||||
assert len(feature.cli_commands()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(self):
|
||||
"""Test health check reports correct state."""
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
# Add some hooks
|
||||
@on_audio_start
|
||||
async def start_hook(session_id):
|
||||
pass
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def chunk_hook(session_id, pcm, sample_rate):
|
||||
pass
|
||||
|
||||
|
||||
@on_audio_end
|
||||
async def end_hook(session_id):
|
||||
pass
|
||||
|
||||
|
||||
health = await feature.health()
|
||||
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["feature"] == "audio"
|
||||
assert health["start_hooks"] == 1
|
||||
@@ -69,74 +67,74 @@ class TestAudioFeature:
|
||||
|
||||
class TestAudioHookRegistration:
|
||||
"""Test audio hook registration."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
def test_start_hook_registration(self):
|
||||
"""Test audio start hook registration."""
|
||||
|
||||
|
||||
@register_audio_start_hook
|
||||
def start_hook():
|
||||
pass
|
||||
|
||||
|
||||
assert len(_audio_start_hooks) == 1
|
||||
assert _audio_start_hooks[0] == start_hook
|
||||
|
||||
|
||||
def test_chunk_hook_registration(self):
|
||||
"""Test audio chunk hook registration."""
|
||||
|
||||
|
||||
@register_audio_chunk_hook
|
||||
def chunk_hook(pcm_data, sample_rate):
|
||||
pass
|
||||
|
||||
|
||||
assert len(_audio_chunk_hooks) == 1
|
||||
assert _audio_chunk_hooks[0] == chunk_hook
|
||||
|
||||
|
||||
def test_end_hook_registration(self):
|
||||
"""Test audio end hook registration."""
|
||||
|
||||
|
||||
@register_audio_end_hook
|
||||
def end_hook():
|
||||
pass
|
||||
|
||||
|
||||
assert len(_audio_end_hooks) == 1
|
||||
assert _audio_end_hooks[0] == end_hook
|
||||
|
||||
|
||||
def test_decorator_syntax(self):
|
||||
"""Test decorator syntax for audio hooks."""
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def start(session_id):
|
||||
pass
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
def chunk(session_id, pcm, sample_rate):
|
||||
pass
|
||||
|
||||
|
||||
@on_audio_end
|
||||
def end(session_id):
|
||||
pass
|
||||
|
||||
|
||||
assert len(_audio_start_hooks) == 1
|
||||
assert len(_audio_chunk_hooks) == 1
|
||||
assert len(_audio_end_hooks) == 1
|
||||
assert _audio_start_hooks[0] == start
|
||||
assert _audio_chunk_hooks[0] == chunk
|
||||
assert _audio_end_hooks[0] == end
|
||||
|
||||
|
||||
def test_duplicate_registration_prevention(self):
|
||||
"""Test that duplicate hook registration is prevented."""
|
||||
|
||||
|
||||
def my_hook():
|
||||
pass
|
||||
|
||||
|
||||
# Register the same hook multiple times
|
||||
register_audio_start_hook(my_hook)
|
||||
register_audio_start_hook(my_hook)
|
||||
register_audio_start_hook(my_hook)
|
||||
|
||||
|
||||
# Should only appear once
|
||||
assert len(_audio_start_hooks) == 1
|
||||
assert _audio_start_hooks[0] == my_hook
|
||||
@@ -144,124 +142,124 @@ class TestAudioHookRegistration:
|
||||
|
||||
class TestAudioHookExecution:
|
||||
"""Test audio hook execution."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_hooks_execution(self):
|
||||
"""Test start hooks are executed."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def hook1(session_id):
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_audio_start
|
||||
async def hook2(session_id):
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def hook3(session_id):
|
||||
execution_order.append(3)
|
||||
|
||||
|
||||
# Create feature instance and trigger hooks
|
||||
feature = AudioFeature()
|
||||
await feature._trigger_start_hooks("test_session")
|
||||
|
||||
|
||||
assert execution_order == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunk_hooks_execution(self):
|
||||
"""Test chunk hooks are executed with correct parameters."""
|
||||
received_chunks = []
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
def hook1(session_id, pcm_data, sample_rate):
|
||||
received_chunks.append(("hook1", len(pcm_data), sample_rate))
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def hook2(session_id, pcm_data, sample_rate):
|
||||
received_chunks.append(("hook2", len(pcm_data), sample_rate))
|
||||
|
||||
|
||||
# Create feature instance and trigger hooks
|
||||
feature = AudioFeature()
|
||||
test_data = b"test_pcm_data"
|
||||
test_rate = 16000
|
||||
|
||||
|
||||
await feature._trigger_chunk_hooks("test_session", test_data, test_rate)
|
||||
|
||||
|
||||
assert len(received_chunks) == 2
|
||||
assert ("hook1", len(test_data), test_rate) in received_chunks
|
||||
assert ("hook2", len(test_data), test_rate) in received_chunks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_hooks_execution(self):
|
||||
"""Test end hooks are executed."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_audio_end
|
||||
def hook1(session_id):
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_audio_end
|
||||
async def hook2(session_id):
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
@on_audio_end
|
||||
def hook3(session_id):
|
||||
execution_order.append(3)
|
||||
|
||||
|
||||
# Create feature instance and trigger hooks
|
||||
feature = AudioFeature()
|
||||
await feature._trigger_end_hooks("test_session")
|
||||
|
||||
|
||||
assert execution_order == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_error_handling(self):
|
||||
"""Test that hook errors don't break audio processing."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def good_hook1(session_id):
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def failing_hook(session_id):
|
||||
execution_order.append("fail")
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def good_hook2(session_id):
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
# Should not raise exception
|
||||
feature = AudioFeature()
|
||||
await feature._trigger_start_hooks("test_session")
|
||||
|
||||
|
||||
# All hooks should have been attempted
|
||||
assert execution_order == [1, "fail", 2]
|
||||
|
||||
|
||||
class TestAudioSessionManagement:
|
||||
"""Test audio session management."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_creation_and_cleanup(self):
|
||||
"""Test audio session lifecycle."""
|
||||
feature = AudioFeature()
|
||||
session_id = "test_session"
|
||||
|
||||
|
||||
# Initially no sessions
|
||||
assert len(_audio_sessions) == 0
|
||||
assert _audio_stats["active_sessions"] == 0
|
||||
|
||||
|
||||
# Simulate session creation
|
||||
_audio_sessions[session_id] = {
|
||||
"started_at": time.time(),
|
||||
@@ -272,24 +270,24 @@ class TestAudioSessionManagement:
|
||||
}
|
||||
_audio_stats["total_sessions"] += 1
|
||||
_audio_stats["active_sessions"] += 1
|
||||
|
||||
|
||||
assert len(_audio_sessions) == 1
|
||||
assert _audio_stats["active_sessions"] == 1
|
||||
assert session_id in _audio_sessions
|
||||
|
||||
|
||||
# Simulate session cleanup
|
||||
del _audio_sessions[session_id]
|
||||
_audio_stats["active_sessions"] = max(0, _audio_stats["active_sessions"] - 1)
|
||||
|
||||
|
||||
assert len(_audio_sessions) == 0
|
||||
assert _audio_stats["active_sessions"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunk_processing_updates_session(self):
|
||||
"""Test that chunk processing updates session stats."""
|
||||
feature = AudioFeature()
|
||||
session_id = "test_session"
|
||||
|
||||
|
||||
# Create session
|
||||
_audio_sessions[session_id] = {
|
||||
"started_at": time.time(),
|
||||
@@ -298,17 +296,17 @@ class TestAudioSessionManagement:
|
||||
"bytes_received": 0,
|
||||
"last_chunk_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
# Process audio chunk
|
||||
test_data = b"test_audio_chunk_data"
|
||||
await feature._handle_audio_chunk(session_id, test_data, 16000)
|
||||
|
||||
|
||||
# Check session was updated
|
||||
session = _audio_sessions[session_id]
|
||||
assert session["chunks_received"] == 1
|
||||
assert session["bytes_received"] == len(test_data)
|
||||
assert session["last_chunk_at"] > session["started_at"]
|
||||
|
||||
|
||||
# Check global stats were updated
|
||||
assert _audio_stats["total_chunks"] == 1
|
||||
assert _audio_stats["total_bytes"] == len(test_data)
|
||||
@@ -316,27 +314,27 @@ class TestAudioSessionManagement:
|
||||
|
||||
class TestAudioStats:
|
||||
"""Test audio statistics tracking."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
def test_initial_stats(self):
|
||||
"""Test initial audio statistics."""
|
||||
assert _audio_stats["total_sessions"] == 0
|
||||
assert _audio_stats["active_sessions"] == 0
|
||||
assert _audio_stats["total_chunks"] == 0
|
||||
assert _audio_stats["total_bytes"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_tracking(self):
|
||||
"""Test statistics are properly tracked."""
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
# Simulate multiple chunks from different sessions
|
||||
sessions = ["session1", "session2"]
|
||||
chunk_data = [b"chunk1", b"chunk2", b"chunk3"]
|
||||
|
||||
|
||||
for session_id in sessions:
|
||||
_audio_sessions[session_id] = {
|
||||
"started_at": time.time(),
|
||||
@@ -347,37 +345,37 @@ class TestAudioStats:
|
||||
}
|
||||
_audio_stats["total_sessions"] += 1
|
||||
_audio_stats["active_sessions"] += 1
|
||||
|
||||
|
||||
total_bytes = 0
|
||||
for session_id in sessions:
|
||||
for chunk in chunk_data:
|
||||
await feature._handle_audio_chunk(session_id, chunk, 16000)
|
||||
total_bytes += len(chunk)
|
||||
|
||||
|
||||
# Check final stats
|
||||
assert _audio_stats["total_sessions"] == 2
|
||||
assert _audio_stats["active_sessions"] == 2
|
||||
assert _audio_stats["total_chunks"] == len(sessions) * len(chunk_data)
|
||||
assert _audio_stats["total_bytes"] == total_bytes
|
||||
|
||||
|
||||
def test_reset_state(self):
|
||||
"""Test state reset functionality."""
|
||||
# Modify state
|
||||
_audio_stats["total_sessions"] = 5
|
||||
_audio_stats["active_sessions"] = 2
|
||||
_audio_sessions["test"] = {}
|
||||
|
||||
|
||||
@on_audio_start
|
||||
def hook(session_id):
|
||||
pass
|
||||
|
||||
|
||||
assert len(_audio_start_hooks) == 1
|
||||
assert _audio_stats["total_sessions"] == 5
|
||||
assert len(_audio_sessions) == 1
|
||||
|
||||
|
||||
# Reset state
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
# Should be back to initial state
|
||||
assert len(_audio_start_hooks) == 0
|
||||
assert _audio_stats["total_sessions"] == 0
|
||||
@@ -386,55 +384,55 @@ class TestAudioStats:
|
||||
|
||||
class TestAudioIntegration:
|
||||
"""Integration tests for audio hooks."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_audio_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stt_pipeline_example(self):
|
||||
"""Test the STT pipeline example from the issue."""
|
||||
# Simulate an STT buffer
|
||||
stt_buffer = {"chunks": [], "finalized": False, "transcript": ""}
|
||||
|
||||
|
||||
@on_audio_start
|
||||
async def start(session_id):
|
||||
# Reset buffer for new session
|
||||
stt_buffer["chunks"].clear()
|
||||
stt_buffer["finalized"] = False
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def chunk(session_id, pcm_data, sample_rate):
|
||||
# Accumulate audio chunks
|
||||
stt_buffer["chunks"].append(pcm_data)
|
||||
|
||||
|
||||
@on_audio_end
|
||||
async def end(session_id):
|
||||
# Simulate STT processing
|
||||
if stt_buffer["chunks"]:
|
||||
stt_buffer["transcript"] = f"Processed {len(stt_buffer['chunks'])} chunks"
|
||||
stt_buffer["finalized"] = True
|
||||
|
||||
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
# Simulate audio session
|
||||
await feature._trigger_start_hooks("test_session")
|
||||
assert len(stt_buffer["chunks"]) == 0
|
||||
assert not stt_buffer["finalized"]
|
||||
|
||||
|
||||
# Send audio chunks
|
||||
chunks = [b"chunk1", b"chunk2", b"chunk3"]
|
||||
for chunk in chunks:
|
||||
await feature._trigger_chunk_hooks("test_session", chunk, 16000)
|
||||
|
||||
|
||||
assert len(stt_buffer["chunks"]) == len(chunks)
|
||||
assert stt_buffer["chunks"] == chunks
|
||||
|
||||
|
||||
# End session
|
||||
await feature._trigger_end_hooks("test_session")
|
||||
assert stt_buffer["finalized"]
|
||||
assert stt_buffer["transcript"] == "Processed 3 chunks"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_stt_providers(self):
|
||||
"""Test multiple STT providers receiving the same audio."""
|
||||
@@ -443,85 +441,85 @@ class TestAudioIntegration:
|
||||
"deepgram": {"chunks": [], "active": False},
|
||||
"vosk": {"chunks": [], "active": False},
|
||||
}
|
||||
|
||||
|
||||
@on_audio_start
|
||||
async def start_all(session_id):
|
||||
for provider in providers.values():
|
||||
provider["active"] = True
|
||||
provider["chunks"].clear()
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def chunk_to_whisper(session_id, pcm_data, sample_rate):
|
||||
if providers["whisper"]["active"]:
|
||||
providers["whisper"]["chunks"].append(pcm_data)
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def chunk_to_deepgram(session_id, pcm_data, sample_rate):
|
||||
if providers["deepgram"]["active"]:
|
||||
providers["deepgram"]["chunks"].append(pcm_data)
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def chunk_to_vosk(session_id, pcm_data, sample_rate):
|
||||
if providers["vosk"]["active"]:
|
||||
providers["vosk"]["chunks"].append(pcm_data)
|
||||
|
||||
|
||||
@on_audio_end
|
||||
async def end_all(session_id):
|
||||
for provider in providers.values():
|
||||
provider["active"] = False
|
||||
|
||||
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
# Start session
|
||||
await feature._trigger_start_hooks("test_session")
|
||||
|
||||
|
||||
# All providers should be active
|
||||
for provider in providers.values():
|
||||
assert provider["active"]
|
||||
assert len(provider["chunks"]) == 0
|
||||
|
||||
|
||||
# Send audio chunk
|
||||
test_chunk = b"test_audio_data"
|
||||
await feature._trigger_chunk_hooks("test_session", test_chunk, 16000)
|
||||
|
||||
|
||||
# All providers should have received the chunk
|
||||
for provider in providers.values():
|
||||
assert len(provider["chunks"]) == 1
|
||||
assert provider["chunks"][0] == test_chunk
|
||||
|
||||
|
||||
# End session
|
||||
await feature._trigger_end_hooks("test_session")
|
||||
|
||||
|
||||
# All providers should be deactivated
|
||||
for provider in providers.values():
|
||||
assert not provider["active"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_session_ordering(self):
|
||||
"""Test that audio chunks are processed in order."""
|
||||
received_chunks = []
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def ordered_processor(session_id, pcm_data, sample_rate):
|
||||
# Decode chunk number from data
|
||||
chunk_num = int(pcm_data.decode())
|
||||
received_chunks.append(chunk_num)
|
||||
|
||||
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
# Send chunks in order
|
||||
for i in range(10):
|
||||
chunk_data = str(i).encode()
|
||||
await feature._trigger_chunk_hooks("test_session", chunk_data, 16000)
|
||||
|
||||
|
||||
# Chunks should be processed in order
|
||||
assert received_chunks == list(range(10))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_audio_sessions(self):
|
||||
"""Test handling of concurrent audio sessions."""
|
||||
session_data = {}
|
||||
|
||||
|
||||
@on_audio_chunk
|
||||
async def track_by_session(session_id, pcm_data, sample_rate):
|
||||
# Extract session ID from chunk data (simulated)
|
||||
@@ -529,22 +527,22 @@ class TestAudioIntegration:
|
||||
if session_id not in session_data:
|
||||
session_data[session_id] = []
|
||||
session_data[session_id].append(pcm_data)
|
||||
|
||||
|
||||
feature = AudioFeature()
|
||||
|
||||
|
||||
# Simulate chunks from multiple sessions
|
||||
sessions = ["session1", "session2", "session3"]
|
||||
chunks_per_session = 3
|
||||
|
||||
|
||||
for session_id in sessions:
|
||||
for chunk_num in range(chunks_per_session):
|
||||
chunk_data = f"{session_id}:chunk{chunk_num}".encode()
|
||||
await feature._trigger_chunk_hooks("test_session", chunk_data, 16000)
|
||||
|
||||
|
||||
# Each session should have received its chunks
|
||||
assert len(session_data) == len(sessions)
|
||||
for session_id in sessions:
|
||||
assert len(session_data[session_id]) == chunks_per_session
|
||||
for i, chunk_data in enumerate(session_data[session_id]):
|
||||
expected = f"{session_id}:chunk{i}".encode()
|
||||
assert chunk_data == expected
|
||||
assert chunk_data == expected
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Unit tests for TokenQueryMiddleware URL token authentication."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.auth import TokenQueryMiddleware, _constant_time_eq
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_token():
|
||||
"""Test token with sufficient entropy."""
|
||||
return "abcdef123456789012345678901234567890"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def middleware_app(test_token):
|
||||
"""Create test Starlette app with TokenQueryMiddleware."""
|
||||
async def success_handler(request):
|
||||
return JSONResponse({"message": "success"})
|
||||
|
||||
async def health_handler(request):
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
middleware = [
|
||||
Middleware(TokenQueryMiddleware, expected_token=test_token)
|
||||
]
|
||||
routes = [
|
||||
Route("/", success_handler),
|
||||
Route("/dashboard", success_handler),
|
||||
Route("/api/chat", success_handler),
|
||||
Route("/health", health_handler),
|
||||
Route("/api/health", health_handler),
|
||||
Route("/custom", success_handler),
|
||||
]
|
||||
return Starlette(middleware=middleware, routes=routes)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(middleware_app):
|
||||
"""Test client for middleware app."""
|
||||
return TestClient(middleware_app)
|
||||
|
||||
|
||||
def test_no_token_unset_env(test_token):
|
||||
"""Test middleware is inert when no token is configured."""
|
||||
async def success_handler(request):
|
||||
return JSONResponse({"message": "success"})
|
||||
|
||||
# Middleware with empty token should pass through
|
||||
middleware = [
|
||||
Middleware(TokenQueryMiddleware, expected_token="")
|
||||
]
|
||||
routes = [Route("/", success_handler)]
|
||||
starlette_app = Starlette(middleware=middleware, routes=routes)
|
||||
client = TestClient(starlette_app)
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "success"}
|
||||
|
||||
|
||||
def test_no_auth_token_required_html_login_form(client):
|
||||
"""Test HTML login form is returned for non-API requests without token."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 401
|
||||
assert "Authentication Required" in response.text
|
||||
assert "<form" in response.text
|
||||
assert 'name="token"' in response.text
|
||||
|
||||
|
||||
def test_no_auth_token_required_api_json_error(client):
|
||||
"""Test JSON error for /api/* requests without token."""
|
||||
response = client.get("/api/chat")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"error": "Unauthorized",
|
||||
"hint": "Append ?token=<SECRET> to the URL or send Authorization: Bearer <SECRET>."
|
||||
}
|
||||
|
||||
|
||||
def test_valid_query_token_redirects_and_sets_cookie(client, test_token):
|
||||
"""Test valid ?token=... redirects to clean URL with cookie."""
|
||||
response = client.get(f"/?token={test_token}", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] in ["/", "http://testserver/"]
|
||||
|
||||
# Check cookie is set
|
||||
assert "praisonaiui_token" in response.cookies
|
||||
assert response.cookies["praisonaiui_token"] == test_token
|
||||
|
||||
|
||||
def test_valid_query_token_with_other_params(client, test_token):
|
||||
"""Test ?token=... preserves other query parameters in redirect."""
|
||||
response = client.get(f"/dashboard?foo=bar&token={test_token}&baz=qux", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
# Token should be stripped but other params preserved
|
||||
location = response.headers["location"]
|
||||
assert "token=" not in location
|
||||
assert "foo=bar" in location
|
||||
assert "baz=qux" in location
|
||||
|
||||
|
||||
def test_invalid_query_token_returns_401(client):
|
||||
"""Test invalid ?token=... returns 401."""
|
||||
response = client.get("/?token=wrongtoken")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_valid_bearer_header_passes(client, test_token):
|
||||
"""Test valid Authorization: Bearer header passes through."""
|
||||
headers = {"Authorization": f"Bearer {test_token}"}
|
||||
response = client.get("/", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "success"}
|
||||
|
||||
|
||||
def test_valid_bearer_header_api_endpoint(client, test_token):
|
||||
"""Test valid Bearer token works for API endpoints."""
|
||||
headers = {"Authorization": f"Bearer {test_token}"}
|
||||
response = client.get("/api/chat", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "success"}
|
||||
|
||||
|
||||
def test_invalid_bearer_header_returns_401(client):
|
||||
"""Test invalid Bearer token returns 401."""
|
||||
headers = {"Authorization": "Bearer wrongtoken"}
|
||||
response = client.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_valid_cookie_passes(client, test_token):
|
||||
"""Test valid cookie passes through."""
|
||||
client.cookies["praisonaiui_token"] = test_token
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "success"}
|
||||
|
||||
|
||||
def test_invalid_cookie_returns_401(client):
|
||||
"""Test invalid cookie returns 401."""
|
||||
client.cookies["praisonaiui_token"] = "wrongtoken"
|
||||
response = client.get("/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_exempt_paths_always_pass(test_token):
|
||||
"""Test exempt paths (/health, /api/health) always pass."""
|
||||
async def health_handler(request):
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
middleware = [
|
||||
Middleware(TokenQueryMiddleware, expected_token=test_token)
|
||||
]
|
||||
routes = [
|
||||
Route("/health", health_handler),
|
||||
Route("/api/health", health_handler),
|
||||
]
|
||||
starlette_app = Starlette(middleware=middleware, routes=routes)
|
||||
client = TestClient(starlette_app)
|
||||
|
||||
# No auth required for exempt paths
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_custom_exempt_paths(test_token):
|
||||
"""Test custom exempt paths work."""
|
||||
async def handler(request):
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
middleware = [
|
||||
Middleware(TokenQueryMiddleware, expected_token=test_token, exempt_paths=["/custom"])
|
||||
]
|
||||
routes = [
|
||||
Route("/custom", handler),
|
||||
Route("/health", handler),
|
||||
]
|
||||
starlette_app = Starlette(middleware=middleware, routes=routes)
|
||||
client = TestClient(starlette_app)
|
||||
|
||||
# Custom exempt path should work
|
||||
response = client.get("/custom")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Default exempt paths should still work
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_constant_time_comparison():
|
||||
"""Test _constant_time_eq prevents timing attacks."""
|
||||
correct = "secret123456789012345678901234567890"
|
||||
|
||||
# Test equal strings
|
||||
assert _constant_time_eq(correct, correct) is True
|
||||
|
||||
# Test different strings (same length)
|
||||
assert _constant_time_eq(correct, "wrong1234567890123456789012345678901") is False
|
||||
|
||||
# Test different lengths
|
||||
assert _constant_time_eq(correct, "short") is False
|
||||
assert _constant_time_eq("short", correct) is False
|
||||
|
||||
# Test empty strings
|
||||
assert _constant_time_eq("", "") is True
|
||||
assert _constant_time_eq(correct, "") is False
|
||||
|
||||
|
||||
def test_timing_attack_resistance():
|
||||
"""Test timing attack resistance - all incorrect tokens take similar time."""
|
||||
correct = "secret123456789012345678901234567890"
|
||||
wrong_same_length = "wrongg123456789012345678901234567890"
|
||||
wrong_diff_length = "wrong"
|
||||
|
||||
# Measure times for different wrong tokens
|
||||
times = []
|
||||
for wrong_token in [wrong_same_length, wrong_diff_length, ""]:
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000): # Multiple iterations for better timing measurement
|
||||
_constant_time_eq(correct, wrong_token)
|
||||
end = time.perf_counter()
|
||||
times.append(end - start)
|
||||
|
||||
# Times should be similar (within reasonable variance)
|
||||
# This is a best-effort test - timing can be affected by system load
|
||||
avg_time = sum(times) / len(times)
|
||||
for t in times:
|
||||
# Allow up to 50% variance due to system noise
|
||||
assert abs(t - avg_time) / avg_time < 0.5, f"Timing variance too high: {times}"
|
||||
|
||||
|
||||
def test_cookie_security_attributes(client, test_token):
|
||||
"""Test cookie security attributes are set correctly."""
|
||||
# Test HTTP request (no Secure flag)
|
||||
response = client.get(f"/?token={test_token}", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
cookie_header = response.headers.get("set-cookie", "")
|
||||
assert "HttpOnly" in cookie_header
|
||||
assert "SameSite=lax" in cookie_header
|
||||
assert "Max-Age=86400" in cookie_header # 24 hours
|
||||
# Secure flag should not be set for HTTP
|
||||
assert "Secure" not in cookie_header
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"AIUI_URL_TOKEN": "test_env_token"})
|
||||
def test_env_var_integration():
|
||||
"""Test environment variable integration would work in server setup."""
|
||||
# This tests the env var reading logic that would be used in create_app()
|
||||
token = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
|
||||
assert token == "test_env_token"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"GATEWAY_AUTH_TOKEN": "gateway_token"})
|
||||
def test_gateway_auth_token_fallback():
|
||||
"""Test GATEWAY_AUTH_TOKEN fallback when AIUI_URL_TOKEN not set."""
|
||||
# Remove AIUI_URL_TOKEN if it exists
|
||||
os.environ.pop("AIUI_URL_TOKEN", None)
|
||||
|
||||
token = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
|
||||
assert token == "gateway_token"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"AIUI_URL_TOKEN": "aiui_token", "GATEWAY_AUTH_TOKEN": "gateway_token"})
|
||||
def test_aiui_url_token_takes_precedence():
|
||||
"""Test AIUI_URL_TOKEN takes precedence over GATEWAY_AUTH_TOKEN."""
|
||||
token = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
|
||||
assert token == "aiui_token"
|
||||
+112
-106
@@ -2,63 +2,64 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features.lifecycle import (
|
||||
LifecycleFeature,
|
||||
on_app_startup,
|
||||
on_app_shutdown,
|
||||
register_startup_hook,
|
||||
register_shutdown_hook,
|
||||
run_startup_hooks,
|
||||
run_shutdown_hooks,
|
||||
reset_lifecycle_state,
|
||||
_startup_hooks,
|
||||
_shutdown_hooks,
|
||||
_lifecycle_state,
|
||||
_shutdown_hooks,
|
||||
_startup_hooks,
|
||||
on_app_shutdown,
|
||||
on_app_startup,
|
||||
register_shutdown_hook,
|
||||
register_startup_hook,
|
||||
reset_lifecycle_state,
|
||||
run_shutdown_hooks,
|
||||
run_startup_hooks,
|
||||
)
|
||||
|
||||
|
||||
class TestLifecycleFeature:
|
||||
"""Test LifecycleFeature protocol implementation."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_lifecycle_state()
|
||||
|
||||
|
||||
def test_feature_protocol(self):
|
||||
"""Test that LifecycleFeature implements BaseFeatureProtocol correctly."""
|
||||
feature = LifecycleFeature()
|
||||
|
||||
|
||||
assert feature.name == "lifecycle"
|
||||
assert feature.description == "Server startup and shutdown hook management"
|
||||
assert len(feature.routes()) == 2
|
||||
assert len(feature.cli_commands()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(self):
|
||||
"""Test health check reports correct state."""
|
||||
feature = LifecycleFeature()
|
||||
|
||||
|
||||
# Add some hooks
|
||||
@on_app_startup
|
||||
async def startup1():
|
||||
pass
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def shutdown1():
|
||||
pass
|
||||
|
||||
|
||||
health = await feature.health()
|
||||
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["feature"] == "lifecycle"
|
||||
assert health["startup_hooks"] == 1
|
||||
assert health["shutdown_hooks"] == 1
|
||||
assert health["startup_completed"] is False
|
||||
|
||||
|
||||
# After startup
|
||||
await run_startup_hooks()
|
||||
health = await feature.health()
|
||||
@@ -67,218 +68,218 @@ class TestLifecycleFeature:
|
||||
|
||||
class TestLifecycleHooks:
|
||||
"""Test lifecycle hook registration and execution."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_lifecycle_state()
|
||||
|
||||
|
||||
def test_startup_hook_registration(self):
|
||||
"""Test startup hook registration."""
|
||||
call_count = 0
|
||||
|
||||
|
||||
@register_startup_hook
|
||||
def startup_hook():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
|
||||
assert len(_startup_hooks) == 1
|
||||
assert _startup_hooks[0] == startup_hook
|
||||
|
||||
|
||||
def test_shutdown_hook_registration(self):
|
||||
"""Test shutdown hook registration."""
|
||||
call_count = 0
|
||||
|
||||
|
||||
@register_shutdown_hook
|
||||
def shutdown_hook():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
|
||||
assert len(_shutdown_hooks) == 1
|
||||
assert _shutdown_hooks[0] == shutdown_hook
|
||||
|
||||
|
||||
def test_decorator_syntax(self):
|
||||
"""Test decorator syntax for hooks."""
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def startup():
|
||||
pass
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
def shutdown():
|
||||
pass
|
||||
|
||||
|
||||
assert len(_startup_hooks) == 1
|
||||
assert len(_shutdown_hooks) == 1
|
||||
assert _startup_hooks[0] == startup
|
||||
assert _shutdown_hooks[0] == shutdown
|
||||
|
||||
|
||||
def test_duplicate_registration_prevention(self):
|
||||
"""Test that duplicate hook registration is prevented."""
|
||||
|
||||
|
||||
def my_hook():
|
||||
pass
|
||||
|
||||
|
||||
# Register the same hook multiple times
|
||||
register_startup_hook(my_hook)
|
||||
register_startup_hook(my_hook)
|
||||
register_startup_hook(my_hook)
|
||||
|
||||
|
||||
# Should only appear once
|
||||
assert len(_startup_hooks) == 1
|
||||
assert _startup_hooks[0] == my_hook
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_hooks_execution(self):
|
||||
"""Test startup hooks are executed in order."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def hook1():
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_app_startup
|
||||
async def hook2():
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def hook3():
|
||||
execution_order.append(3)
|
||||
|
||||
|
||||
assert _lifecycle_state["startup_completed"] is False
|
||||
|
||||
|
||||
await run_startup_hooks()
|
||||
|
||||
|
||||
assert execution_order == [1, 2, 3]
|
||||
assert _lifecycle_state["startup_completed"] is True
|
||||
assert _lifecycle_state["startup_time"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_hooks_execution(self):
|
||||
"""Test shutdown hooks are executed in order."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
def hook1():
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def hook2():
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
def hook3():
|
||||
execution_order.append(3)
|
||||
|
||||
|
||||
assert _lifecycle_state["shutdown_initiated"] is False
|
||||
|
||||
|
||||
await run_shutdown_hooks()
|
||||
|
||||
|
||||
assert execution_order == [1, 2, 3]
|
||||
assert _lifecycle_state["shutdown_initiated"] is True
|
||||
assert _lifecycle_state["shutdown_time"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_hook_failure_handling(self):
|
||||
"""Test that startup hook failures don't break the process."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def good_hook1():
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def failing_hook():
|
||||
execution_order.append("fail")
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def good_hook2():
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
# Should not raise exception
|
||||
await run_startup_hooks()
|
||||
|
||||
|
||||
# All hooks should have been attempted
|
||||
assert execution_order == [1, "fail", 2]
|
||||
assert _lifecycle_state["startup_completed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_hook_failure_handling(self):
|
||||
"""Test that shutdown hook failures don't break the process."""
|
||||
execution_order = []
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
def good_hook1():
|
||||
execution_order.append(1)
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def failing_hook():
|
||||
execution_order.append("fail")
|
||||
raise RuntimeError("Async hook failed")
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
def good_hook2():
|
||||
execution_order.append(2)
|
||||
|
||||
|
||||
# Should not raise exception
|
||||
await run_shutdown_hooks()
|
||||
|
||||
|
||||
# All hooks should have been attempted
|
||||
assert execution_order == [1, "fail", 2]
|
||||
assert _lifecycle_state["shutdown_initiated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_timeout(self):
|
||||
"""Test shutdown timeout mechanism."""
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def slow_hook():
|
||||
await asyncio.sleep(2) # Simulate slow shutdown
|
||||
|
||||
|
||||
# Set short timeout
|
||||
with patch.dict(os.environ, {"AIUI_SHUTDOWN_TIMEOUT": "0.1"}):
|
||||
start_time = time.time()
|
||||
await run_shutdown_hooks()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
|
||||
# Should timeout quickly
|
||||
assert elapsed < 0.5
|
||||
assert _lifecycle_state["shutdown_initiated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_idempotent(self):
|
||||
"""Test that startup hooks are only run once."""
|
||||
call_count = 0
|
||||
|
||||
|
||||
@on_app_startup
|
||||
def increment_hook():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
|
||||
# Run startup hooks multiple times
|
||||
await run_startup_hooks()
|
||||
await run_startup_hooks()
|
||||
await run_startup_hooks()
|
||||
|
||||
|
||||
# Hook should only be called once
|
||||
assert call_count == 1
|
||||
assert _lifecycle_state["startup_completed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_idempotent(self):
|
||||
"""Test that shutdown hooks are only run once."""
|
||||
call_count = 0
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
def increment_hook():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
|
||||
# Run shutdown hooks multiple times
|
||||
await run_shutdown_hooks()
|
||||
await run_shutdown_hooks()
|
||||
await run_shutdown_hooks()
|
||||
|
||||
|
||||
# Hook should only be called once
|
||||
assert call_count == 1
|
||||
assert _lifecycle_state["shutdown_initiated"] is True
|
||||
@@ -286,34 +287,35 @@ class TestLifecycleHooks:
|
||||
|
||||
class TestLifecycleState:
|
||||
"""Test lifecycle state management."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_lifecycle_state()
|
||||
|
||||
|
||||
def test_initial_state(self):
|
||||
"""Test initial lifecycle state."""
|
||||
assert _lifecycle_state["startup_completed"] is False
|
||||
assert _lifecycle_state["shutdown_initiated"] is False
|
||||
assert _lifecycle_state["startup_time"] == 0
|
||||
assert _lifecycle_state["shutdown_time"] == 0
|
||||
|
||||
|
||||
def test_reset_state(self):
|
||||
"""Test state reset functionality."""
|
||||
|
||||
# Add some hooks and change state
|
||||
@on_app_startup
|
||||
def hook():
|
||||
pass
|
||||
|
||||
|
||||
_lifecycle_state["startup_completed"] = True
|
||||
_lifecycle_state["startup_time"] = 1.5
|
||||
|
||||
|
||||
assert len(_startup_hooks) == 1
|
||||
assert _lifecycle_state["startup_completed"] is True
|
||||
|
||||
|
||||
# Reset state
|
||||
reset_lifecycle_state()
|
||||
|
||||
|
||||
# Should be back to initial state
|
||||
assert len(_startup_hooks) == 0
|
||||
assert _lifecycle_state["startup_completed"] is False
|
||||
@@ -322,110 +324,114 @@ class TestLifecycleState:
|
||||
|
||||
class TestLifecycleIntegration:
|
||||
"""Integration tests for lifecycle hooks."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_lifecycle_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_store_example(self):
|
||||
"""Test the vector store example from the issue."""
|
||||
# Simulate a vector store
|
||||
vector_store = {"connected": False, "closed": False}
|
||||
|
||||
|
||||
@on_app_startup
|
||||
async def warm():
|
||||
# Simulate async connection
|
||||
await asyncio.sleep(0.01)
|
||||
vector_store["connected"] = True
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def drain():
|
||||
# Simulate async cleanup
|
||||
await asyncio.sleep(0.01)
|
||||
vector_store["closed"] = True
|
||||
|
||||
|
||||
# Initially not connected
|
||||
assert vector_store["connected"] is False
|
||||
|
||||
|
||||
# Run startup
|
||||
await run_startup_hooks()
|
||||
assert vector_store["connected"] is True
|
||||
assert _lifecycle_state["startup_completed"] is True
|
||||
|
||||
|
||||
# Run shutdown
|
||||
await run_shutdown_hooks()
|
||||
assert vector_store["closed"] is True
|
||||
assert _lifecycle_state["shutdown_initiated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_lifecycle(self):
|
||||
"""Test complete resource lifecycle with startup and shutdown."""
|
||||
resource_states = []
|
||||
|
||||
|
||||
class MockResource:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.initialized = False
|
||||
self.cleaned_up = False
|
||||
|
||||
|
||||
async def initialize(self):
|
||||
resource_states.append(f"{self.name}_init")
|
||||
self.initialized = True
|
||||
|
||||
|
||||
async def cleanup(self):
|
||||
resource_states.append(f"{self.name}_cleanup")
|
||||
self.cleaned_up = True
|
||||
|
||||
|
||||
# Create resources
|
||||
db = MockResource("db")
|
||||
cache = MockResource("cache")
|
||||
queue = MockResource("queue")
|
||||
|
||||
|
||||
# Register startup hooks
|
||||
@on_app_startup
|
||||
async def init_db():
|
||||
await db.initialize()
|
||||
|
||||
|
||||
@on_app_startup
|
||||
async def init_cache():
|
||||
await cache.initialize()
|
||||
|
||||
|
||||
@on_app_startup
|
||||
async def init_queue():
|
||||
await queue.initialize()
|
||||
|
||||
|
||||
# Register shutdown hooks (reverse order)
|
||||
@on_app_shutdown
|
||||
async def cleanup_queue():
|
||||
await queue.cleanup()
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def cleanup_cache():
|
||||
await cache.cleanup()
|
||||
|
||||
|
||||
@on_app_shutdown
|
||||
async def cleanup_db():
|
||||
await db.cleanup()
|
||||
|
||||
|
||||
# Run lifecycle
|
||||
await run_startup_hooks()
|
||||
|
||||
|
||||
# All resources should be initialized
|
||||
assert db.initialized
|
||||
assert cache.initialized
|
||||
assert queue.initialized
|
||||
|
||||
|
||||
await run_shutdown_hooks()
|
||||
|
||||
|
||||
# All resources should be cleaned up
|
||||
assert db.cleaned_up
|
||||
assert cache.cleaned_up
|
||||
assert queue.cleaned_up
|
||||
|
||||
|
||||
# Check execution order
|
||||
expected_order = [
|
||||
"db_init", "cache_init", "queue_init",
|
||||
"queue_cleanup", "cache_cleanup", "db_cleanup"
|
||||
"db_init",
|
||||
"cache_init",
|
||||
"queue_init",
|
||||
"queue_cleanup",
|
||||
"cache_cleanup",
|
||||
"db_cleanup",
|
||||
]
|
||||
assert resource_states == expected_order
|
||||
assert resource_states == expected_order
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for PraisonAIUI-native public API names (non-developer-friendly).
|
||||
|
||||
These names are shorter, plain-English aliases for symbols we inherited
|
||||
from the broader agent-UI ecosystem. The aliases resolve to *the exact
|
||||
same objects* so both names behave identically at runtime. Users should
|
||||
prefer the native names in new code; the originals remain supported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
# ── Ask* message family ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_name, old_name",
|
||||
[
|
||||
("TextPrompt", "AskUserMessage"),
|
||||
("FilePrompt", "AskFileMessage"),
|
||||
("ChoicePrompt", "AskActionMessage"),
|
||||
("LocationPrompt", "AskElementMessage"),
|
||||
],
|
||||
)
|
||||
def test_prompt_family_native_aliases(new_name: str, old_name: str) -> None:
|
||||
assert getattr(aiui, new_name) is getattr(aiui, old_name)
|
||||
|
||||
|
||||
# ── UI / browser-callable functions ─────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_name, old_name",
|
||||
[
|
||||
("UIFunction", "CopilotFunction"),
|
||||
("UIFunctionParameter", "CopilotFunctionParameter"),
|
||||
("ui_function", "copilot_function"),
|
||||
("on_ui_function", "on_copilot_function_call"),
|
||||
("call_ui_function", "call_copilot_function"),
|
||||
("get_ui_function", "get_copilot_function"),
|
||||
("get_ui_functions", "get_copilot_functions"),
|
||||
],
|
||||
)
|
||||
def test_ui_function_native_aliases(new_name: str, old_name: str) -> None:
|
||||
# UIFunctionParameter isn't re-exported at aiui top-level; check module
|
||||
if new_name == "UIFunctionParameter":
|
||||
from praisonaiui import copilot
|
||||
|
||||
assert getattr(copilot, new_name) is getattr(copilot, old_name)
|
||||
return
|
||||
assert getattr(aiui, new_name) is getattr(aiui, old_name)
|
||||
|
||||
|
||||
# ── Settings ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_settings_alias() -> None:
|
||||
assert aiui.Settings is aiui.ChatSettings
|
||||
|
||||
|
||||
def test_on_settings_change_is_callable_decorator() -> None:
|
||||
@aiui.on_settings_change
|
||||
def handler(payload: dict) -> None:
|
||||
pass
|
||||
|
||||
# Should register under the same underlying callback registry as the
|
||||
# legacy name
|
||||
from praisonaiui.chat_settings import get_settings_handlers
|
||||
|
||||
assert handler in get_settings_handlers()
|
||||
|
||||
|
||||
# ── Auth callbacks ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_name, old_name",
|
||||
[
|
||||
("on_oauth_login", "oauth_callback"),
|
||||
("on_header_login", "header_auth_callback"),
|
||||
("on_password_login", "password_auth_callback"),
|
||||
("on_shared_view", "on_shared_thread_view"),
|
||||
],
|
||||
)
|
||||
def test_auth_native_aliases(new_name: str, old_name: str) -> None:
|
||||
assert getattr(aiui, new_name) is getattr(aiui, old_name)
|
||||
|
||||
|
||||
# ── Lifecycle / window / audio / slack ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_name, old_name",
|
||||
[
|
||||
("on_startup", "on_app_startup"),
|
||||
("on_shutdown", "on_app_shutdown"),
|
||||
("on_parent_message", "on_window_message"),
|
||||
("send_to_parent", "send_window_message"),
|
||||
("on_mic_start", "on_audio_start"),
|
||||
("on_mic_data", "on_audio_chunk"),
|
||||
("on_mic_stop", "on_audio_end"),
|
||||
("on_slack_reaction", "on_slack_reaction_added"),
|
||||
],
|
||||
)
|
||||
def test_event_hook_native_aliases(new_name: str, old_name: str) -> None:
|
||||
assert getattr(aiui, new_name) is getattr(aiui, old_name)
|
||||
|
||||
|
||||
# ── Public surface advertises the native names ──────────────────────
|
||||
|
||||
|
||||
def test_native_names_in_dunder_all() -> None:
|
||||
expected = {
|
||||
"TextPrompt",
|
||||
"FilePrompt",
|
||||
"ChoicePrompt",
|
||||
"LocationPrompt",
|
||||
"UIFunction",
|
||||
"ui_function",
|
||||
"on_ui_function",
|
||||
"call_ui_function",
|
||||
"get_ui_function",
|
||||
"get_ui_functions",
|
||||
"Settings",
|
||||
"on_settings_change",
|
||||
"on_oauth_login",
|
||||
"on_header_login",
|
||||
"on_password_login",
|
||||
"on_shared_view",
|
||||
"on_slack_reaction",
|
||||
"on_startup",
|
||||
"on_shutdown",
|
||||
"on_parent_message",
|
||||
"send_to_parent",
|
||||
"on_mic_start",
|
||||
"on_mic_data",
|
||||
"on_mic_stop",
|
||||
}
|
||||
missing = expected - set(aiui.__all__)
|
||||
assert not missing, f"Missing from __all__: {sorted(missing)}"
|
||||
@@ -1,56 +1,54 @@
|
||||
"""Tests for window message feature."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from praisonaiui.features.window_message import (
|
||||
WindowMessageFeature,
|
||||
on_window_message,
|
||||
register_window_message_hook,
|
||||
handle_window_message,
|
||||
send_window_message,
|
||||
reset_window_message_state,
|
||||
_window_message_hooks,
|
||||
_message_log,
|
||||
_session_contexts,
|
||||
_window_message_hooks,
|
||||
handle_window_message,
|
||||
on_window_message,
|
||||
register_window_message_hook,
|
||||
reset_window_message_state,
|
||||
send_window_message,
|
||||
)
|
||||
|
||||
|
||||
class TestWindowMessageFeature:
|
||||
"""Test WindowMessageFeature protocol implementation."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_window_message_state()
|
||||
|
||||
|
||||
def test_feature_protocol(self):
|
||||
"""Test that WindowMessageFeature implements BaseFeatureProtocol correctly."""
|
||||
feature = WindowMessageFeature()
|
||||
|
||||
|
||||
assert feature.name == "window_message"
|
||||
assert feature.description == "Browser window.postMessage communication"
|
||||
assert len(feature.routes()) == 5 # Added receive endpoint
|
||||
assert len(feature.cli_commands()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(self):
|
||||
"""Test health check reports correct state."""
|
||||
feature = WindowMessageFeature()
|
||||
|
||||
|
||||
# Add some hooks
|
||||
@on_window_message("parent")
|
||||
async def parent_hook(data):
|
||||
pass
|
||||
|
||||
|
||||
@on_window_message()
|
||||
async def wildcard_hook(data):
|
||||
pass
|
||||
|
||||
|
||||
health = await feature.health()
|
||||
|
||||
|
||||
assert health["status"] == "ok"
|
||||
assert health["feature"] == "window_message"
|
||||
assert health["total_hooks"] == 2
|
||||
@@ -61,130 +59,130 @@ class TestWindowMessageFeature:
|
||||
|
||||
class TestWindowMessageHooks:
|
||||
"""Test window message hook registration and execution."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_window_message_state()
|
||||
|
||||
|
||||
def test_hook_registration(self):
|
||||
"""Test window message hook registration."""
|
||||
|
||||
|
||||
@register_window_message_hook("parent", lambda data: None)
|
||||
def parent_hook(data):
|
||||
pass
|
||||
|
||||
|
||||
@register_window_message_hook(None, lambda data: None)
|
||||
def wildcard_hook(data):
|
||||
pass
|
||||
|
||||
|
||||
assert "parent" in _window_message_hooks
|
||||
assert "*" in _window_message_hooks
|
||||
assert len(_window_message_hooks["parent"]) == 1
|
||||
assert len(_window_message_hooks["*"]) == 1
|
||||
|
||||
|
||||
def test_decorator_syntax(self):
|
||||
"""Test decorator syntax for window message hooks."""
|
||||
|
||||
|
||||
@on_window_message("https://example.com")
|
||||
def specific_origin_hook(data):
|
||||
pass
|
||||
|
||||
|
||||
@on_window_message()
|
||||
def any_origin_hook(data):
|
||||
pass
|
||||
|
||||
|
||||
assert "https://example.com" in _window_message_hooks
|
||||
assert "*" in _window_message_hooks
|
||||
assert len(_window_message_hooks["https://example.com"]) == 1
|
||||
assert len(_window_message_hooks["*"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_handling_with_specific_source(self):
|
||||
"""Test message handling with specific source matching."""
|
||||
calls = []
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def parent_hook(data):
|
||||
calls.append(("parent", data))
|
||||
|
||||
|
||||
@on_window_message("https://example.com")
|
||||
async def example_hook(data):
|
||||
calls.append(("example", data))
|
||||
|
||||
|
||||
# Send message from parent
|
||||
await handle_window_message({"type": "test", "value": 1}, "parent")
|
||||
|
||||
|
||||
# Send message from example.com
|
||||
await handle_window_message({"type": "test", "value": 2}, "https://example.com")
|
||||
|
||||
|
||||
# Send message from unknown source
|
||||
await handle_window_message({"type": "test", "value": 3}, "https://other.com")
|
||||
|
||||
|
||||
assert len(calls) == 2
|
||||
assert ("parent", {"type": "test", "value": 1}) in calls
|
||||
assert ("example", {"type": "test", "value": 2}) in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_handling_with_wildcard(self):
|
||||
"""Test message handling with wildcard hooks."""
|
||||
calls = []
|
||||
|
||||
|
||||
@on_window_message() # Wildcard
|
||||
async def any_hook(data):
|
||||
calls.append(data)
|
||||
|
||||
|
||||
# Send messages from different sources
|
||||
await handle_window_message({"type": "test1"}, "parent")
|
||||
await handle_window_message({"type": "test2"}, "https://example.com")
|
||||
await handle_window_message({"type": "test3"}, None)
|
||||
|
||||
|
||||
assert len(calls) == 3
|
||||
assert {"type": "test1"} in calls
|
||||
assert {"type": "test2"} in calls
|
||||
assert {"type": "test3"} in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_handling_with_multiple_hooks(self):
|
||||
"""Test message handling with multiple matching hooks."""
|
||||
calls = []
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def parent_hook1(data):
|
||||
calls.append(("parent1", data))
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def parent_hook2(data):
|
||||
calls.append(("parent2", data))
|
||||
|
||||
|
||||
@on_window_message()
|
||||
async def wildcard_hook(data):
|
||||
calls.append(("wildcard", data))
|
||||
|
||||
|
||||
await handle_window_message({"type": "test"}, "parent")
|
||||
|
||||
|
||||
# Should call all matching hooks
|
||||
assert len(calls) == 3
|
||||
assert ("parent1", {"type": "test"}) in calls
|
||||
assert ("parent2", {"type": "test"}) in calls
|
||||
assert ("wildcard", {"type": "test"}) in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_error_handling(self):
|
||||
"""Test that hook errors don't break message processing."""
|
||||
calls = []
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def failing_hook(data):
|
||||
calls.append("failing")
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def working_hook(data):
|
||||
calls.append("working")
|
||||
|
||||
|
||||
# Should not raise exception
|
||||
await handle_window_message({"type": "test"}, "parent")
|
||||
|
||||
|
||||
# Both hooks should have been attempted
|
||||
assert "failing" in calls
|
||||
assert "working" in calls
|
||||
@@ -192,28 +190,28 @@ class TestWindowMessageHooks:
|
||||
|
||||
class TestWindowMessageLogging:
|
||||
"""Test window message logging functionality."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_window_message_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_message_logging(self):
|
||||
"""Test that inbound messages are logged."""
|
||||
await handle_window_message({"type": "test"}, "parent")
|
||||
|
||||
|
||||
assert len(_message_log) == 1
|
||||
log_entry = _message_log[0]
|
||||
assert log_entry["type"] == "window_message_inbound"
|
||||
assert log_entry["data"] == {"type": "test"}
|
||||
assert log_entry["source"] == "parent"
|
||||
assert "timestamp" in log_entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_message_logging(self):
|
||||
"""Test that outbound messages are logged."""
|
||||
await send_window_message({"type": "response"}, "parent")
|
||||
|
||||
|
||||
assert len(_message_log) == 1
|
||||
log_entry = _message_log[0]
|
||||
assert log_entry["type"] == "window_message_outbound"
|
||||
@@ -224,52 +222,52 @@ class TestWindowMessageLogging:
|
||||
|
||||
class TestWindowMessageSending:
|
||||
"""Test window message sending functionality."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_window_message_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_parent(self):
|
||||
"""Test sending message to parent window."""
|
||||
await send_window_message({"type": "hello", "message": "test"})
|
||||
|
||||
|
||||
assert len(_message_log) == 1
|
||||
log_entry = _message_log[0]
|
||||
assert log_entry["type"] == "window_message_outbound"
|
||||
assert log_entry["data"]["type"] == "hello"
|
||||
assert log_entry["target"] == "parent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_custom_target(self):
|
||||
"""Test sending message with custom target."""
|
||||
await send_window_message({"type": "hello"}, "https://example.com")
|
||||
|
||||
|
||||
assert len(_message_log) == 1
|
||||
log_entry = _message_log[0]
|
||||
assert log_entry["target"] == "https://example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_invalid_target(self):
|
||||
"""Test sending message with invalid target is rejected."""
|
||||
await send_window_message({"type": "hello"}, "invalid-target")
|
||||
|
||||
|
||||
# Message should not be logged if target is invalid
|
||||
assert len(_message_log) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_session_context(self):
|
||||
"""Test sending message with active session context."""
|
||||
# Mock session context with SSE queue
|
||||
sse_queue = asyncio.Queue()
|
||||
|
||||
|
||||
# Add a session context for testing
|
||||
test_session_id = "test_session"
|
||||
_session_contexts[test_session_id] = {"sse_queue": sse_queue}
|
||||
|
||||
|
||||
try:
|
||||
await send_window_message({"type": "test"}, "parent")
|
||||
|
||||
|
||||
# Should have queued message for SSE
|
||||
assert not sse_queue.empty()
|
||||
message = await sse_queue.get()
|
||||
@@ -284,76 +282,76 @@ class TestWindowMessageSending:
|
||||
|
||||
class TestWindowMessageSecurity:
|
||||
"""Test window message security features."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_window_message_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_filtering(self):
|
||||
"""Test that hooks can filter by origin."""
|
||||
calls = []
|
||||
|
||||
|
||||
@on_window_message("https://trusted.com")
|
||||
async def trusted_hook(data):
|
||||
calls.append("trusted")
|
||||
|
||||
|
||||
@on_window_message("https://evil.com")
|
||||
async def evil_hook(data):
|
||||
calls.append("evil")
|
||||
|
||||
|
||||
# Send from trusted origin
|
||||
await handle_window_message({"type": "test"}, "https://trusted.com")
|
||||
assert calls == ["trusted"]
|
||||
|
||||
|
||||
calls.clear()
|
||||
|
||||
|
||||
# Send from evil origin
|
||||
await handle_window_message({"type": "test"}, "https://evil.com")
|
||||
assert calls == ["evil"]
|
||||
|
||||
|
||||
calls.clear()
|
||||
|
||||
|
||||
# Send from unknown origin
|
||||
await handle_window_message({"type": "test"}, "https://unknown.com")
|
||||
assert calls == [] # No hooks should be called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_target_validation_in_send(self):
|
||||
"""Test target validation in send_window_message."""
|
||||
valid_targets = ["parent", "*", "https://example.com", "http://localhost:3000"]
|
||||
invalid_targets = ["javascript:alert(1)", "data:text/html", "file:///etc/passwd"]
|
||||
|
||||
|
||||
for target in valid_targets:
|
||||
await send_window_message({"type": "test"}, target)
|
||||
|
||||
|
||||
# Should have logged valid messages
|
||||
valid_count = len([t for t in valid_targets if t not in ["javascript:alert(1)"]])
|
||||
assert len(_message_log) == len(valid_targets)
|
||||
|
||||
|
||||
# Reset log
|
||||
_message_log.clear()
|
||||
|
||||
|
||||
for target in invalid_targets:
|
||||
await send_window_message({"type": "test"}, target)
|
||||
|
||||
|
||||
# Should not have logged invalid messages
|
||||
assert len(_message_log) == 0
|
||||
|
||||
|
||||
class TestWindowMessageIntegration:
|
||||
"""Integration tests for window message feature."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset state before each test."""
|
||||
reset_window_message_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_context_example(self):
|
||||
"""Test the user context example from the issue."""
|
||||
user_context = {}
|
||||
response_sent = {}
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def on_msg(data):
|
||||
if data.get("type") == "set_user":
|
||||
@@ -361,73 +359,60 @@ class TestWindowMessageIntegration:
|
||||
# Simulate sending response
|
||||
response_sent["type"] = "user_set"
|
||||
response_sent["ok"] = True
|
||||
|
||||
|
||||
# Simulate receiving user context message
|
||||
await handle_window_message({
|
||||
"type": "set_user",
|
||||
"email": "[email protected]"
|
||||
}, "parent")
|
||||
|
||||
await handle_window_message({"type": "set_user", "email": "[email protected]"}, "parent")
|
||||
|
||||
assert user_context["email"] == "[email protected]"
|
||||
assert response_sent["type"] == "user_set"
|
||||
assert response_sent["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bidirectional_communication(self):
|
||||
"""Test bidirectional communication between iframe and parent."""
|
||||
received_messages = []
|
||||
|
||||
|
||||
@on_window_message("parent")
|
||||
async def handle_parent_message(data):
|
||||
received_messages.append(data)
|
||||
|
||||
|
||||
# Send acknowledgment
|
||||
await send_window_message({
|
||||
"type": "ack",
|
||||
"received": data["type"]
|
||||
}, "parent")
|
||||
|
||||
await send_window_message({"type": "ack", "received": data["type"]}, "parent")
|
||||
|
||||
# Simulate receiving message from parent
|
||||
await handle_window_message({
|
||||
"type": "config_update",
|
||||
"theme": "dark"
|
||||
}, "parent")
|
||||
|
||||
await handle_window_message({"type": "config_update", "theme": "dark"}, "parent")
|
||||
|
||||
# Check message was received
|
||||
assert len(received_messages) == 1
|
||||
assert received_messages[0]["type"] == "config_update"
|
||||
assert received_messages[0]["theme"] == "dark"
|
||||
|
||||
|
||||
# Check acknowledgment was sent
|
||||
outbound_logs = [log for log in _message_log if log["type"] == "window_message_outbound"]
|
||||
assert len(outbound_logs) == 1
|
||||
assert outbound_logs[0]["data"]["type"] == "ack"
|
||||
assert outbound_logs[0]["data"]["received"] == "config_update"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_iframe_sources(self):
|
||||
"""Test handling messages from multiple iframe sources."""
|
||||
messages_by_source = {}
|
||||
|
||||
|
||||
@on_window_message()
|
||||
async def handle_any_message(data):
|
||||
source = data.get("source", "unknown")
|
||||
if source not in messages_by_source:
|
||||
messages_by_source[source] = []
|
||||
messages_by_source[source].append(data)
|
||||
|
||||
|
||||
# Simulate messages from different sources
|
||||
sources = ["parent", "https://widget1.com", "https://widget2.com"]
|
||||
|
||||
|
||||
for i, source in enumerate(sources):
|
||||
await handle_window_message({
|
||||
"type": "status",
|
||||
"source": source,
|
||||
"id": i
|
||||
}, source)
|
||||
|
||||
await handle_window_message({"type": "status", "source": source, "id": i}, source)
|
||||
|
||||
# Check all messages were received
|
||||
assert len(messages_by_source) == len(sources)
|
||||
for source in sources:
|
||||
assert source in messages_by_source
|
||||
assert len(messages_by_source[source]) == 1
|
||||
assert len(messages_by_source[source]) == 1
|
||||
Reference in new issue
Block a user