Compare commits

...
7 Commits
Author SHA1 Message Date
Mervin PraisonandMervinPraison bf3721e484 feat(api): PraisonAIUI-native public names (non-developer-friendly) (closes #41) (#47)
Establishes a plain-English, noun-first public API surface that does not
lift names from unrelated frameworks.  Every original name remains
importable as a silent alias so no existing code breaks.

## Rationale
1. **Non-developer friendly** — a lay reader should understand what a
   symbol does from its name alone (``FilePrompt`` > ``AskFileMessage``,
   ``on_startup`` > ``on_app_startup``, ``on_mic_start`` > ``on_audio_start``).
2. **API-direction compliant** — new class aliases are *noun-first*,
   not verb-first, so they pass the regression fence added in PR #44
   (``tests/unit/test_api_direction.py``).
3. **No overlap with third-party frameworks** — the pre-rename audit
   showed ~25 public names that read as direct lifts.  These now have
   native replacements.

## Native aliases (new primary names)

| Native (new)          | Legacy (still supported)        |
|-----------------------|---------------------------------|
| TextPrompt            | AskUserMessage                  |
| FilePrompt            | AskFileMessage                  |
| ChoicePrompt          | AskActionMessage                |
| LocationPrompt        | AskElementMessage               |
| UIFunction            | CopilotFunction                 |
| UIFunctionParameter   | CopilotFunctionParameter        |
| ui_function           | copilot_function                |
| on_ui_function        | on_copilot_function_call        |
| call_ui_function      | call_copilot_function           |
| get_ui_function(s)    | get_copilot_function(s)         |
| Settings              | ChatSettings                    |
| on_settings_change    | on_settings_update              |
| on_oauth_login        | oauth_callback                  |
| on_header_login       | header_auth_callback            |
| on_password_login     | password_auth_callback          |
| on_shared_view        | on_shared_thread_view           |
| on_slack_reaction     | on_slack_reaction_added         |
| 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                    |

Generic agent/Python vocabulary (``Message``, ``User``, ``Task``,
``Step``, ``Action``, ``ErrorMessage``, ``CustomElement``, ``sleep``,
``make_async``, ``run_sync``, ``Plotly``, ``Pyplot``, ``Dataframe``,
``instrument_openai``, ``on_mcp_connect``) is kept as-is — these are
industry-standard terms, not lifts.

## Tests
- ``tests/unit/test_native_naming.py`` — 26 new tests proving each
  new name resolves to the exact same object as its legacy counterpart
  and is exported in ``praisonaiui.__all__``.

Full suite: **940 pass**, 4 skipped, 8 xfailed, 1 xpassed.

## Version
Bumps to 0.3.112; fixes a pre-existing mismatch where ``pyproject.toml``
advertised 0.3.111 while ``__version__.py`` still said 0.3.110.

Co-authored-by: MervinPraison <[email protected]>
2026-04-22 14:05:36 +01:00
MervinPraison 5e8ce01c58 feat(version): bump version to 0.3.111
Updated version in pyproject.toml and uv.lock to reflect the new release.
2026-04-22 13:34:49 +01:00
52946ee0ac feat: add API direction regression fence (Phase 0) (#44)
* feat: add API direction regression fence (fixes #41 Phase 0)

Implements Phase 0 of epic #41 to prevent API direction regression:

- Prevents new Ask*Message classes beyond documented 4
- Blocks Message classes with verb prefixes (Ask/Do/Request/Show/Get/Set/Send/Create)
- Enforces on_*/get_* naming for new callback decorators
- Validates exported symbol consistency
- Uses data-driven allow-lists for easy maintenance

Tests pass on current main and successfully catch violations.
Supports framework-neutral verb-first API direction.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

* style: fix ruff violations in test_api_direction.py

- Remove unused import (inspect)
- Fix trailing whitespace and blank lines
- Format code to comply with ruff standards

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-04-22 12:54:27 +01:00
2976fb9b4d feat(auth): URL query-parameter token middleware for share-by-URL dashboards (fixes #39) (#45)
Add TokenQueryMiddleware supporting Jupyter-style ?token=SECRET authentication:

- Query param → redirect + secure cookie flow to prevent token leaks
- Constant-time comparison using hmac.compare_digest for timing attack protection
- Environment variable integration (AIUI_URL_TOKEN or GATEWAY_AUTH_TOKEN fallback)
- Extended AuthConfig with url_token provider and configuration fields
- Comprehensive test coverage (19 tests covering all authentication scenarios)
- Import time verified under 200ms requirement
- Opt-in via environment variables (inert when not configured)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-04-22 12:51:06 +01:00
41ad9a6df1 fix: ruff --fix pass over audio/lifecycle/window_message test files (fixes #37) (#46)
- Fixed 307 linting errors (W293 trailing whitespace, F401 unused imports, W292 missing EOL)
- Formatted 2 files for consistency
- All 56 tests still pass
- Import time constraint maintained at 165.7ms (<200ms limit)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-04-22 12:51:02 +01:00
Mervin PraisonandMervinPraison dcf2f4da06 chore(ci): expand claude.yml + add auto-issue-comment.yml (#43)
- claude.yml: add issues:[opened,labeled,assigned] + pull_request_review_comment triggers
- claude.yml: add issue-triage job (welcome comment on new issues)
- claude.yml: expand direct_prompt with PraisonAIUI-specific routing (STEP 0-6):
  - architecture routing (core / features / UI builders / frontend / tests / docs)
  - external-repo routing (PraisonAI / PraisonAI-Tools / PraisonAIDocs)
  - conceptual checks (no duplicate exports, no class-first Ask*Message, lazy
    optional deps, real DeprecationWarning on deprecations)
  - import-time < 200ms + no heavy-dep leak invariant
  - PR body template (Summary, Before/After, SHA-cited checklist, test evidence,
    import-time proof)
  - STEP 6 reviewer-loop instructions (respond to CodeRabbit/Copilot/human
    review comments, push to same branch)
- auto-issue-comment.yml: optional workflow to escalate an issue to Claude by
  adding the 'claude' label (workflow_dispatch-only; issues:opened trigger
  commented out by default)

CodeRabbit + Copilot: no workflow file needed — both are GitHub Apps that
react to PRs automatically. The chain (CodeRabbit → Copilot → Claude) is
already orchestrated by auto-pr-comment.yml.

Co-authored-by: MervinPraison <[email protected]>
2026-04-22 06:12:18 +01:00
Mervin PraisonandMervinPraison a8bb34ec92 chore(ci): expand claude.yml + add auto-issue-comment.yml (#42)
- claude.yml: add issues:[opened,labeled,assigned] + pull_request_review_comment triggers
- claude.yml: add issue-triage job (welcome comment on new issues)
- claude.yml: expand direct_prompt with PraisonAIUI-specific routing (STEP 0-6):
  - architecture routing (core / features / UI builders / frontend / tests / docs)
  - external-repo routing (PraisonAI / PraisonAI-Tools / PraisonAIDocs)
  - conceptual checks (no duplicate exports, no class-first Ask*Message, lazy
    optional deps, real DeprecationWarning on deprecations)
  - import-time < 200ms + no heavy-dep leak invariant
  - PR body template (Summary, Before/After, SHA-cited checklist, test evidence,
    import-time proof)
  - STEP 6 reviewer-loop instructions (respond to CodeRabbit/Copilot/human
    review comments, push to same branch)
- auto-issue-comment.yml: optional workflow to escalate an issue to Claude by
  adding the 'claude' label (workflow_dispatch-only; issues:opened trigger
  commented out by default)

CodeRabbit + Copilot: no workflow file needed — both are GitHub Apps that
react to PRs automatically. The chain (CodeRabbit → Copilot → Claude) is
already orchestrated by auto-pr-comment.yml.

Co-authored-by: MervinPraison <[email protected]>
2026-04-22 06:09:33 +01:00
23 changed files with 1635 additions and 378 deletions

No files matched your search

+86
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+63 -1
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.110"
__version__ = "0.3.112"
+127 -2
View File
@@ -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
+13
View File
@@ -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)
+14
View File
@@ -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
+9
View File
@@ -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
+8
View File
@@ -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
+5
View File
@@ -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
+13
View File
@@ -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
+5 -1
View File
@@ -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)
+9
View File
@@ -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(
+286
View File
@@ -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
View File
@@ -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
+285
View File
@@ -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
View File
@@ -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
+142
View File
@@ -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)}"
+98 -113
View File
@@ -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
Generated
+1 -1
View File
@@ -198,7 +198,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.110"
version = "0.3.111"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },