2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
import re
|
2026-05-27 21:06:18 +02:00
|
|
|
from playwright.sync_api import expect
|
Attach images pasted into the composer, comments and chat
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.
The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.
It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.
Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:23:25 +02:00
|
|
|
from tests.conftest import BASE_URL, assert_share_copies, paste_image
|
2026-05-10 09:08:12 +02:00
|
|
|
def create_post(page, topic="random", content="Test post content", title=None):
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
|
|
|
|
page.locator(".feed-fab").first.click()
|
2026-05-10 09:08:12 +02:00
|
|
|
page.check(f"input[value='{topic}']")
|
|
|
|
|
page.fill("#post-content", content)
|
|
|
|
|
if title:
|
|
|
|
|
page.fill("#post-title", title)
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
2026-05-11 00:41:41 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
2026-06-13 16:32:33 +02:00
|
|
|
def _profile_stars(page, username):
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
|
|
|
|
value = page.locator(
|
|
|
|
|
".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value"
|
|
|
|
|
).first
|
|
|
|
|
return int(value.text_content().strip())
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_detail_page(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Detail page test content")
|
|
|
|
|
assert page.is_visible("text=Detail page test content")
|
|
|
|
|
assert page.is_visible("text=Back to Feed")
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 00:44:12 +02:00
|
|
|
def test_create_post_modal_targets_modal_textarea_on_populated_feed(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Seed post so the feed renders comment forms")
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
|
|
|
|
assert page.locator("textarea[name='content']").count() > 1
|
|
|
|
|
page.locator(".feed-fab").first.click()
|
|
|
|
|
modal_content = page.locator("#create-post-modal textarea[name='content']")
|
|
|
|
|
modal_content.wait_for(state="visible", timeout=10000)
|
|
|
|
|
modal_content.click()
|
|
|
|
|
modal_content.fill("Modal scoped body well over ten characters long.")
|
|
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Modal scoped body well over ten characters long.")
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_post_topic_badge(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "question", "Question post for badge check")
|
|
|
|
|
badge = page.locator(".badge-question")
|
|
|
|
|
assert badge.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_author_info(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
create_post(page, "fun", "Post with author check")
|
|
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_vote_button(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Votable post")
|
2026-05-23 03:21:55 +02:00
|
|
|
vote_btn = page.locator(".post-action-btn.vote-up").first
|
2026-05-10 09:08:12 +02:00
|
|
|
assert vote_btn.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_vote_increment(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Vote increment test")
|
2026-05-23 03:21:55 +02:00
|
|
|
page.locator(".post-action-btn.vote-up").first.click()
|
2026-05-27 21:06:18 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
def test_post_upvote_voted_state_persists(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Upvote persistence test")
|
2026-06-14 16:46:36 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".post-action-btn.vote-up").first.click()
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
|
|
|
|
page.reload(wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
|
|
|
|
assert "voted" not in (
|
|
|
|
|
page.locator(".post-action-btn.vote-down").first.get_attribute("class") or ""
|
|
|
|
|
)
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_downvote_voted_state_persists(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Downvote persistence test")
|
2026-06-14 16:46:36 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".post-action-btn.vote-down").first.click()
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("-1")
|
|
|
|
|
page.reload(wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
|
|
|
|
assert "voted" not in (
|
|
|
|
|
page.locator(".post-action-btn.vote-up").first.get_attribute("class") or ""
|
|
|
|
|
)
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_voted_state_persists(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for comment vote persistence")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
|
|
|
|
textarea.fill("Comment whose vote should persist")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('Comment whose vote should persist')")
|
|
|
|
|
).to_be_visible()
|
2026-08-16 02:02:34 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".comment-vote-btn").first.click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
page.reload(wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
|
|
|
|
|
|
2026-06-19 10:06:09 +02:00
|
|
|
def test_mention_first_match_highlighted(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention highlight test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
item = page.locator(".mention-dropdown-item:has-text('bob_test')").first
|
|
|
|
|
item.wait_for(state="visible", timeout=5000)
|
|
|
|
|
expect(item).to_have_class(re.compile(r"\bactive\b"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_tab_inserts_first_match(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention tab insert test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
page.locator(".mention-dropdown-item:has-text('bob_test')").first.wait_for(
|
|
|
|
|
state="visible", timeout=5000
|
|
|
|
|
)
|
|
|
|
|
textarea.press("Tab")
|
|
|
|
|
expect(textarea).to_have_value(re.compile(r"^@bob_test\s"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_arrow_then_enter_inserts(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention arrow nav test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
page.locator(".mention-dropdown-item:has-text('bob_test')").first.wait_for(
|
|
|
|
|
state="visible", timeout=5000
|
|
|
|
|
)
|
|
|
|
|
textarea.press("ArrowDown")
|
2026-06-20 01:21:53 +02:00
|
|
|
selected = page.locator(".mention-dropdown-item.active").first
|
|
|
|
|
selected.wait_for(state="visible", timeout=5000)
|
|
|
|
|
username = selected.get_attribute("data-username")
|
2026-06-19 10:06:09 +02:00
|
|
|
textarea.press("Enter")
|
2026-06-20 01:21:53 +02:00
|
|
|
expect(textarea).to_have_value(re.compile(rf"@{re.escape(username)}\s"))
|
2026-06-19 10:06:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_escape_closes_dropdown(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention escape test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
item = page.locator(".mention-dropdown-item:has-text('bob_test')").first
|
|
|
|
|
item.wait_for(state="visible", timeout=5000)
|
|
|
|
|
textarea.press("Escape")
|
|
|
|
|
expect(item).to_be_hidden()
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 10:54:45 +02:00
|
|
|
def test_profile_stars_reflect_content_votes(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
before = _profile_stars(page, user["username"])
|
|
|
|
|
create_post(page, "devlog", "Reputation contribution post")
|
2026-06-19 10:06:09 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".post-action-btn.vote-up").first.click()
|
2026-05-27 21:06:18 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
2026-05-23 10:54:45 +02:00
|
|
|
after = _profile_stars(page, user["username"])
|
|
|
|
|
assert after == before + 1, f"expected stars {before + 1}, got {after}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_post_comments_section(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "rant", "Comment section test")
|
|
|
|
|
assert page.is_visible("text=Comments")
|
|
|
|
|
assert page.is_visible("textarea[placeholder='Your opinion goes here...']")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_add_comment(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for commenting")
|
2026-05-11 07:02:06 +02:00
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
2026-05-10 09:08:12 +02:00
|
|
|
textarea.fill("This is a test comment from Playwright")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('This is a test comment from Playwright')")
|
|
|
|
|
).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_voting(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for comment voting")
|
2026-05-11 07:02:06 +02:00
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
2026-05-10 09:08:12 +02:00
|
|
|
textarea.fill("Votable comment")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(page.locator(".comment-text:has-text('Votable comment')")).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
vote_btns = page.locator(".comment-vote-btn")
|
|
|
|
|
upvote = vote_btns.first
|
|
|
|
|
upvote.click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(vote_btns.first).to_have_class(re.compile(r"\bvoted\b"))
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_own_comment(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "fun", "Post for comment deletion")
|
2026-05-11 07:02:06 +02:00
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
2026-05-10 09:08:12 +02:00
|
|
|
textarea.fill("Comment to delete")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
comment = page.locator(".comment-text:has-text('Comment to delete')")
|
|
|
|
|
expect(comment).to_be_visible()
|
2026-05-11 05:30:51 +02:00
|
|
|
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(delete_btn).to_be_visible()
|
|
|
|
|
delete_btn.click()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(comment).to_have_count(0)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_form_elements(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for checking comment form")
|
|
|
|
|
assert page.is_visible("textarea[placeholder='Your opinion goes here...']")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_button(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Share button check")
|
2026-05-23 03:21:55 +02:00
|
|
|
assert_share_copies(page, "/posts/")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_comments_on_post(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post with many comments")
|
|
|
|
|
for i in range(3):
|
2026-06-09 18:48:08 +02:00
|
|
|
page.locator(".comment-form textarea[name='content']").fill(
|
|
|
|
|
f"Comment number {i + 1}"
|
|
|
|
|
)
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(f".comment-text:has-text('Comment number {i + 1}')")
|
|
|
|
|
).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("text=Comment number 1")
|
|
|
|
|
assert page.is_visible("text=Comment number 3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_and_vote_then_delete(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Full comment lifecycle post")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
comment = page.locator(".comment-text:has-text('Lifecycle comment')")
|
|
|
|
|
expect(comment).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
vote_up = page.locator(".comment-vote-btn").first
|
|
|
|
|
vote_up.click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
|
|
|
|
expect(delete_btn).to_be_visible()
|
|
|
|
|
delete_btn.click()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(comment).to_have_count(0)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
2026-06-05 19:22:29 +02:00
|
|
|
def test_comment_reply_inline_form(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for inline reply")
|
|
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Parent comment here")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('Parent comment here')")
|
|
|
|
|
).to_be_visible()
|
2026-06-05 19:22:29 +02:00
|
|
|
page.locator(".comment [data-action='reply']").first.click()
|
|
|
|
|
reply_form = page.locator(".comment-reply-form").first
|
|
|
|
|
expect(reply_form).to_be_visible()
|
|
|
|
|
reply_form.locator("textarea[name='content']").fill("Inline reply text")
|
|
|
|
|
reply_form.locator("button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-replies .comment-text:has-text('Inline reply text')")
|
|
|
|
|
).to_be_visible()
|
2026-06-05 19:22:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_reply_toggle_and_cancel(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for reply toggle")
|
|
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Toggle parent")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
|
|
|
|
expect(page.locator(".comment-text:has-text('Toggle parent')")).to_be_visible()
|
|
|
|
|
reply_btn = page.locator(".comment [data-action='reply']").first
|
|
|
|
|
reply_btn.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(1)
|
|
|
|
|
reply_btn.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(0)
|
|
|
|
|
reply_btn.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(1)
|
|
|
|
|
page.locator(".comment-reply-cancel").first.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_create_scrolls_to_anchor(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for comment anchor")
|
|
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Anchor comment text")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('Anchor comment text')")
|
|
|
|
|
).to_be_visible()
|
2026-06-05 19:22:29 +02:00
|
|
|
assert "#comment-" in page.url
|
|
|
|
|
comment_uid = page.url.split("#comment-")[1]
|
|
|
|
|
page.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000)
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(f"#comment-{comment_uid}")).to_contain_text(
|
|
|
|
|
"Anchor comment text"
|
|
|
|
|
)
|
2026-06-05 19:22:29 +02:00
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_post_across_all_topics(alice):
|
|
|
|
|
page, _ = alice
|
2026-07-06 05:57:47 +02:00
|
|
|
topics = ["devlog", "showcase", "question", "rant", "fun", "politics"]
|
2026-05-10 09:08:12 +02:00
|
|
|
for topic in topics:
|
|
|
|
|
create_post(page, topic, f"Topic test post for {topic}")
|
|
|
|
|
badge = page.locator(f".badge-{topic}")
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(badge).to_be_visible()
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_content_rendering_markdown_and_highlight(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-09 18:48:08 +02:00
|
|
|
create_post(
|
|
|
|
|
page, "random", "Hello **world bold** text\n\n```python\nprint('hi')\n```"
|
|
|
|
|
)
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page.locator(".rendered-content strong").first.wait_for(state="visible")
|
|
|
|
|
assert page.locator(".rendered-content pre code").count() >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_autocomplete(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for mention autocomplete here")
|
|
|
|
|
ta = page.locator(".comment-form textarea[data-mention]").first
|
|
|
|
|
ta.click()
|
|
|
|
|
ta.press_sequentially("@bob")
|
|
|
|
|
item = page.locator(".mention-dropdown-item").first
|
|
|
|
|
item.wait_for(state="visible")
|
|
|
|
|
assert "bob" in item.inner_text().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_emoji_picker_opens(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for emoji picker here")
|
|
|
|
|
page.locator(".emoji-toggle-btn").first.click()
|
|
|
|
|
page.locator(".emoji-picker-wrapper").first.wait_for(state="visible")
|
|
|
|
|
assert page.locator("emoji-picker").first.is_visible()
|
|
|
|
|
|
|
|
|
|
|
Attach images pasted into the composer, comments and chat
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.
The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.
It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.
Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:23:25 +02:00
|
|
|
def test_paste_image_attaches_in_comment_form(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for pasted comment attachment")
|
|
|
|
|
page.locator(".comment-form dp-upload .dp-upload-btn").first.wait_for(
|
|
|
|
|
state="visible", timeout=10000
|
|
|
|
|
)
|
|
|
|
|
paste_image(page, ".comment-form textarea[name='content']")
|
|
|
|
|
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
|
|
|
|
|
state="visible", timeout=15000
|
|
|
|
|
)
|
|
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-form dp-upload input[name='attachment_uids']")
|
|
|
|
|
).to_have_value(re.compile(r".+"))
|
|
|
|
|
|
|
|
|
|
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
def test_attachment_upload_ui(alice):
|
|
|
|
|
import io
|
|
|
|
|
from PIL import Image
|
2026-06-09 18:48:08 +02:00
|
|
|
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for attachment upload UI")
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
file_input = page.locator(".comment-form dp-upload .dp-upload-input").first
|
2026-06-09 18:48:08 +02:00
|
|
|
file_input.set_input_files(
|
|
|
|
|
{"name": "pic.png", "mimeType": "image/png", "buffer": buf.getvalue()}
|
|
|
|
|
)
|
2026-06-19 00:09:34 +02:00
|
|
|
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
|
2026-06-09 18:48:08 +02:00
|
|
|
state="visible", timeout=15000
|
|
|
|
|
)
|
|
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-form dp-upload input[name='attachment_uids']")
|
|
|
|
|
).to_have_value(re.compile(r".+"))
|