2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
from tests.conftest import BASE_URL
|
|
|
|
|
import time
|
|
|
|
|
import requests
|
|
|
|
|
from playwright.sync_api import expect
|
|
|
|
|
from devplacepy.database import (
|
|
|
|
|
CUSTOMIZATION_GLOBAL_SCOPE,
|
|
|
|
|
get_customization_prefs,
|
|
|
|
|
get_table,
|
|
|
|
|
list_custom_overrides,
|
|
|
|
|
set_custom_override,
|
|
|
|
|
set_customization_pref,
|
|
|
|
|
)
|
|
|
|
|
from devplacepy.utils import clear_user_cache
|
|
|
|
|
def _user_customization_toggle(username):
|
|
|
|
|
return get_table("users").find_one(username=username)
|
|
|
|
|
def _seed_global_css(uid, sentinel):
|
|
|
|
|
set_custom_override(
|
|
|
|
|
"user", uid, CUSTOMIZATION_GLOBAL_SCOPE, "css", f".{sentinel} {{ color: red; }}"
|
|
|
|
|
)
|
|
|
|
|
def _profile(page, username):
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
|
|
|
|
def _click_toggle(page, category):
|
|
|
|
|
page.locator(f"[data-customization-toggle='{category}']").click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
2026-06-14 17:20:35 +02:00
|
|
|
def _goto_feed_until(page, predicate, timeout=5.0):
|
|
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
content = ""
|
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
content = page.content()
|
|
|
|
|
if predicate(content):
|
|
|
|
|
return content
|
|
|
|
|
time.sleep(0.2)
|
|
|
|
|
return content
|
2026-06-13 16:32:33 +02:00
|
|
|
import re
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-08-09 11:39:53 +02:00
|
|
|
from tests.conftest import (
|
|
|
|
|
BASE_URL,
|
|
|
|
|
assert_no_horizontal_overflow,
|
|
|
|
|
assert_share_copies,
|
|
|
|
|
)
|
2026-06-13 16:32:33 +02:00
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
def _seed_posts(count):
|
|
|
|
|
suite = uuid4().hex[:8]
|
|
|
|
|
topic = f"pag{suite}"
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
posts = get_table("posts")
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
for i in range(count):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
users.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"pag_{suite}_{i}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{suite}_{i}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
content = f"Paginated post {i}"
|
|
|
|
|
posts.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(content, uid),
|
|
|
|
|
"title": None,
|
|
|
|
|
"content": content,
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return topic
|
|
|
|
|
def _seed_post_with_comments(comment_texts, topic="devlog"):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"seed_{owner[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{owner[:8]}@seed.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
post_uid = str(uuid4())
|
|
|
|
|
marker = f"seedpost-{post_uid[:8]}"
|
|
|
|
|
get_table("posts").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": post_uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(marker, post_uid),
|
|
|
|
|
"title": None,
|
|
|
|
|
"content": marker,
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
comments = get_table("comments")
|
|
|
|
|
base = datetime.now(timezone.utc)
|
|
|
|
|
for i, text in enumerate(comment_texts):
|
|
|
|
|
comments.insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": str(uuid4()),
|
|
|
|
|
"target_type": "post",
|
|
|
|
|
"target_uid": post_uid,
|
|
|
|
|
"post_uid": post_uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"content": text,
|
|
|
|
|
"parent_uid": None,
|
|
|
|
|
"created_at": (base + timedelta(seconds=i)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return marker, post_uid
|
|
|
|
|
def _create_post_ui(page, content, topic="devlog"):
|
|
|
|
|
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()
|
|
|
|
|
page.check(f"input[value='{topic}']")
|
|
|
|
|
page.fill("#post-content", content)
|
|
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
|
|
|
|
def _seed_searchable_posts():
|
|
|
|
|
token = uuid4().hex[:10]
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
posts = get_table("posts")
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
match_title = f"Findable post {token}"
|
|
|
|
|
other_title = f"Unrelated post {uuid4().hex[:10]}"
|
|
|
|
|
for offset, title in ((0, match_title), (1, other_title)):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
users.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"srch_{owner[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{owner[:8]}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
posts.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(title, uid),
|
|
|
|
|
"title": title,
|
|
|
|
|
"content": f"Body for {title}",
|
|
|
|
|
"topic": "devlog",
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=offset)).isoformat(),
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return token, match_title, other_title
|
|
|
|
|
from devplacepy.utils import generate_uid
|
|
|
|
|
DEFAULT_MAINTENANCE_MESSAGE = (
|
|
|
|
|
"DevPlace is undergoing scheduled maintenance. Please check back shortly."
|
|
|
|
|
)
|
|
|
|
|
OPERATIONAL_FIELDS = (
|
|
|
|
|
"rate_limit_per_minute",
|
|
|
|
|
"rate_limit_window_seconds",
|
|
|
|
|
"session_max_age_days",
|
|
|
|
|
"session_remember_days",
|
|
|
|
|
"registration_open",
|
|
|
|
|
"maintenance_mode",
|
|
|
|
|
"maintenance_message",
|
|
|
|
|
)
|
|
|
|
|
def _save_settings(page, **fields):
|
|
|
|
|
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
|
|
|
|
|
for name, value in fields.items():
|
|
|
|
|
locator = page.locator(f"#{name}")
|
|
|
|
|
tag = locator.evaluate("el => el.tagName.toLowerCase()")
|
|
|
|
|
if tag == "select":
|
|
|
|
|
page.select_option(f"#{name}", value)
|
|
|
|
|
else:
|
|
|
|
|
locator.fill(value)
|
|
|
|
|
page.click("button:has-text('Save Settings')")
|
|
|
|
|
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
|
|
|
|
|
def create_post(page, topic="random", content="Test post content", title=None):
|
|
|
|
|
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()
|
|
|
|
|
page.check(f"input[value='{topic}']")
|
|
|
|
|
page.fill("#post-content", content)
|
|
|
|
|
if title:
|
|
|
|
|
page.fill("#post-title", title)
|
|
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
|
|
|
|
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())
|
|
|
|
|
def _key_role_visibility(username):
|
|
|
|
|
return get_table("users").find_one(username=username)["api_key"]
|
|
|
|
|
_author_key = []
|
|
|
|
|
def _author():
|
|
|
|
|
# A dedicated Member author so seeding posts never pollutes alice/bob state
|
|
|
|
|
# (notifications, post counts, XP) that other test files assert on.
|
|
|
|
|
if _author_key:
|
|
|
|
|
return _author_key[0]
|
|
|
|
|
name = "rolevis_author"
|
|
|
|
|
requests.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@t.dev",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
row = get_table("users").find_one(username=name)
|
|
|
|
|
get_table("users").update({"uid": row["uid"], "role": "Member"}, ["uid"])
|
|
|
|
|
_author_key.append(row["api_key"])
|
|
|
|
|
return _author_key[0]
|
|
|
|
|
def _seed_post_role_visibility():
|
|
|
|
|
requests.post(
|
|
|
|
|
f"{BASE_URL}/posts/create",
|
|
|
|
|
headers={"X-API-KEY": _author()},
|
|
|
|
|
data={
|
|
|
|
|
"content": "role visibility seed post body text",
|
|
|
|
|
"title": "RoleVis",
|
|
|
|
|
"topic": "random",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
def _seed_news_seo():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
|
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
title = f"SEO Test News Article {uid.split('-')[-1]}"
|
|
|
|
|
slug = make_combined_slug(title, uid)
|
|
|
|
|
get_table("news").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "A seeded news article for SEO tests.",
|
|
|
|
|
"content": "Body content for the seeded article.",
|
|
|
|
|
"url": "https://example.com/article",
|
|
|
|
|
"source_name": "ExampleSource",
|
|
|
|
|
"status": "published",
|
|
|
|
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"show_on_landing": 0,
|
|
|
|
|
"grade": 8,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_owner():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"username": f"seo_{uid[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{uid[:8]}@seo.test",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return uid
|
|
|
|
|
def _seed_post_seo(image=None):
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
slug = make_combined_slug("SEO Detail Post", uid)
|
|
|
|
|
get_table("posts").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": "SEO Detail Post",
|
|
|
|
|
"content": "Body text for the SEO detail post.",
|
|
|
|
|
"topic": "general",
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": image,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_gist():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
slug = make_combined_slug("SEO Detail Gist", uid)
|
|
|
|
|
get_table("gists").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": "SEO Detail Gist",
|
|
|
|
|
"description": "Gist description for SEO tests.",
|
|
|
|
|
"source_code": "print('seo')",
|
|
|
|
|
"language": "python",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_project():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
slug = make_combined_slug("SEO Detail Project", uid)
|
|
|
|
|
get_table("projects").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": "SEO Detail Project",
|
|
|
|
|
"description": "Project description for SEO tests.",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"platforms": "Linux",
|
|
|
|
|
"status": "Released",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_news_image(news_uid):
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
|
|
|
|
|
get_table("news_images").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"news_uid": news_uid,
|
|
|
|
|
"url": "https://example.com/seo-news-image.jpg",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
def _seed_feed_posts(count):
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
topic = f"seopag{owner[:8]}"
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
posts = get_table("posts")
|
|
|
|
|
for i in range(count):
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
posts.insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(f"seo pag {i}", uid),
|
|
|
|
|
"title": None,
|
|
|
|
|
"content": f"seo pag post {i}",
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return topic
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 09:48:10 +02:00
|
|
|
def test_issues_footer_link(alice):
|
2026-06-13 16:32:33 +02:00
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
2026-06-14 09:48:10 +02:00
|
|
|
link = page.locator("a:has-text('Issue Report')")
|
2026-06-13 16:32:33 +02:00
|
|
|
assert link.is_visible()
|
|
|
|
|
link.click()
|
2026-06-14 09:48:10 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_global_toggle_suppresses_rendering_and_persists(bob):
|
|
|
|
|
page, user = bob
|
|
|
|
|
uid = _user_customization_toggle(user["username"])["uid"]
|
|
|
|
|
set_customization_pref(uid, "global", disabled=False)
|
|
|
|
|
sentinel = f"sentinel-{int(time.time() * 1000)}"
|
|
|
|
|
_seed_global_css(uid, sentinel)
|
|
|
|
|
|
2026-06-14 17:20:35 +02:00
|
|
|
assert sentinel in _goto_feed_until(page, lambda c: sentinel in c)
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
_profile(page, user["username"])
|
|
|
|
|
_click_toggle(page, "global")
|
|
|
|
|
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Enable")
|
|
|
|
|
|
2026-06-14 17:20:35 +02:00
|
|
|
assert sentinel not in _goto_feed_until(page, lambda c: sentinel not in c)
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
_profile(page, user["username"])
|
|
|
|
|
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Enable")
|
|
|
|
|
expect(
|
|
|
|
|
page.locator("form[action$='/customization/global'] input[name='value']")
|
|
|
|
|
).to_have_value("1")
|
|
|
|
|
|
|
|
|
|
_click_toggle(page, "global")
|
|
|
|
|
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Disable")
|
2026-06-14 17:20:35 +02:00
|
|
|
assert sentinel in _goto_feed_until(page, lambda c: sentinel in c)
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pagetype_toggle_is_independent(bob):
|
|
|
|
|
page, user = bob
|
|
|
|
|
uid = _user_customization_toggle(user["username"])["uid"]
|
|
|
|
|
set_customization_pref(uid, "global", disabled=False)
|
|
|
|
|
set_customization_pref(uid, "pagetype", disabled=False)
|
|
|
|
|
global_sentinel = f"glob-{int(time.time() * 1000)}"
|
|
|
|
|
page_sentinel = f"page-{int(time.time() * 1000)}"
|
|
|
|
|
_seed_global_css(uid, global_sentinel)
|
|
|
|
|
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page_type = page.locator("main[data-page-type]").get_attribute("data-page-type")
|
|
|
|
|
set_custom_override(
|
|
|
|
|
"user", uid, page_type, "css", f".{page_sentinel} {{ color: blue; }}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-14 17:20:35 +02:00
|
|
|
body = _goto_feed_until(
|
|
|
|
|
page, lambda c: global_sentinel in c and page_sentinel in c
|
|
|
|
|
)
|
2026-06-13 16:32:33 +02:00
|
|
|
assert global_sentinel in body
|
|
|
|
|
assert page_sentinel in body
|
|
|
|
|
|
|
|
|
|
_profile(page, user["username"])
|
|
|
|
|
_click_toggle(page, "pagetype")
|
|
|
|
|
|
2026-06-14 17:20:35 +02:00
|
|
|
body = _goto_feed_until(page, lambda c: page_sentinel not in c)
|
2026-06-13 16:32:33 +02:00
|
|
|
assert global_sentinel in body
|
|
|
|
|
assert page_sentinel not in body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_page_loads(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Topics")
|
|
|
|
|
assert page.is_visible("text=All")
|
|
|
|
|
assert page.is_visible("text=Devlog")
|
|
|
|
|
assert page.is_visible("text=Showcase")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_nav_tabs(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("a:has-text('All')")
|
|
|
|
|
assert page.is_visible("a:has-text('Trending')")
|
|
|
|
|
assert page.is_visible("a:has-text('Recent')")
|
|
|
|
|
assert page.is_visible("a:has-text('Following')")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_tab_switching(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.click("a:has-text('Trending')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed?tab=trending", wait_until="domcontentloaded")
|
|
|
|
|
page.click("a:has-text('Recent')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed?tab=recent", wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_topic_filter_sidebar(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
topics = ["Devlog", "Showcase", "Question", "Rant", "Fun"]
|
|
|
|
|
for topic in topics:
|
2026-06-19 10:06:09 +02:00
|
|
|
link = page.locator(f".sidebar-nav a:has-text('{topic}')").first
|
2026-06-13 16:32:33 +02:00
|
|
|
assert link.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_topic_filter_navigation(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.click("a:has-text('Devlog')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed?topic=devlog", wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_search_bar_visible(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
search = page.locator("input[name='search'][placeholder='Search posts...']")
|
|
|
|
|
assert search.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_search_filters(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
token, match_title, other_title = _seed_searchable_posts()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed?search={token}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator(f".post-title:has-text('{match_title}')").count() == 1
|
|
|
|
|
assert page.locator(f".post-title:has-text('{other_title}')").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_search_no_match(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
_seed_searchable_posts()
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/feed?search=zzz{uuid4().hex}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
assert page.is_visible(".empty-state")
|
|
|
|
|
assert page.locator(".post-title").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_search_preserves_topic(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
token, match_title, _ = _seed_searchable_posts()
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/feed?topic=devlog&search={token}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
assert page.locator(f".post-title:has-text('{match_title}')").count() == 1
|
|
|
|
|
preserved = page.locator(
|
|
|
|
|
".sidebar-card form input[type='hidden'][name='topic'][value='devlog']"
|
|
|
|
|
)
|
|
|
|
|
assert preserved.count() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_community_stats(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Total Members")
|
|
|
|
|
assert page.is_visible("text=Posts Today")
|
|
|
|
|
assert page.is_visible("text=Total Projects")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_top_authors(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Top Authors")
|
|
|
|
|
|
|
|
|
|
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
def _resources_panel(page):
|
|
|
|
|
return page.locator(".sidebar-card .sidebar-section").filter(
|
|
|
|
|
has=page.locator(".sidebar-heading:has-text('Resources')")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_resources_panel_visible(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
panel = _resources_panel(page)
|
|
|
|
|
panel.wait_for(state="visible")
|
|
|
|
|
expect(panel.locator(".sidebar-heading")).to_have_text("Resources")
|
|
|
|
|
expect(panel.locator("a.sidebar-link:has-text('Docs')")).to_be_visible()
|
|
|
|
|
expect(panel.locator("a.sidebar-link:has-text('Issues')")).to_be_visible()
|
|
|
|
|
expect(panel.locator("a.sidebar-link[data-devii-open]")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_resources_docs_link(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
docs = _resources_panel(page).locator("a.sidebar-link:has-text('Docs')")
|
|
|
|
|
assert docs.get_attribute("href") == "/docs"
|
|
|
|
|
docs.click()
|
2026-06-16 08:50:16 +02:00
|
|
|
page.wait_for_url("**/docs/index.html", wait_until="domcontentloaded")
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_resources_issues_link(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
issues = _resources_panel(page).locator("a.sidebar-link:has-text('Issues')")
|
|
|
|
|
assert issues.get_attribute("href") == "/issues"
|
|
|
|
|
issues.click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/issues", wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_resources_devii_opens_assistant(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
devii = _resources_panel(page).locator("a.sidebar-link[data-devii-open]")
|
|
|
|
|
expect(devii).to_be_visible()
|
|
|
|
|
assert devii.get_attribute("href") == "/devii/"
|
|
|
|
|
expect(devii).to_have_text(re.compile("Devii"))
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
def test_feed_daily_topic(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Daily Topic")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_post_fab(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
fab = page.locator(".feed-fab")
|
|
|
|
|
assert fab.is_visible()
|
|
|
|
|
fab.click()
|
|
|
|
|
assert page.is_visible("text=Create New Post")
|
|
|
|
|
assert page.is_visible("#post-content")
|
|
|
|
|
assert page.is_visible("#create-post-modal button:has-text('Post')")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_topnav_notification_bell(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
bell = page.locator(".topnav-icon[href='/notifications']")
|
|
|
|
|
assert bell.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_topnav_user_menu(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_inline_comment_placeholder(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
marker = f"placeholder-{uuid4().hex[:8]}"
|
|
|
|
|
_create_post_ui(page, f"{marker} placeholder check post")
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".post-card").filter(has_text=marker).first
|
|
|
|
|
inline_input = card.locator(".comment-form textarea[name='content']")
|
|
|
|
|
expect(inline_input).to_be_visible()
|
|
|
|
|
assert inline_input.get_attribute("placeholder") == "Your opinion goes here..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_shows_last_three_comments(page, app_server):
|
|
|
|
|
m = uuid4().hex[:6]
|
|
|
|
|
marker, _ = _seed_post_with_comments(
|
|
|
|
|
[f"{m}-one", f"{m}-two", f"{m}-three", f"{m}-four"]
|
|
|
|
|
)
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".post-card").filter(has_text=marker).first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
previews = card.locator(".post-card-comments .comment-text")
|
|
|
|
|
expect(previews).to_have_count(3)
|
|
|
|
|
expect(previews.nth(0)).to_contain_text(f"{m}-two")
|
|
|
|
|
expect(previews.nth(1)).to_contain_text(f"{m}-three")
|
|
|
|
|
expect(previews.nth(2)).to_contain_text(f"{m}-four")
|
|
|
|
|
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{m}-one")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_guest_vote_disabled(page, app_server):
|
|
|
|
|
marker, _ = _seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".post-card").filter(has_text=marker).first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
assert card.locator(".post-action-btn.vote-up").first.is_disabled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_guest_reply_disabled(page, app_server):
|
|
|
|
|
marker, _ = _seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".post-card").filter(has_text=marker).first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
assert card.locator(".post-card-comments [data-action='reply']").first.is_disabled()
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 05:57:47 +02:00
|
|
|
def test_feed_politics_topic(alice):
|
2026-06-13 16:32:33 +02:00
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
2026-07-06 05:57:47 +02:00
|
|
|
politics_link = page.locator(".sidebar-nav a:has-text('Politics')")
|
|
|
|
|
assert politics_link.is_visible()
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_post_cancel_modal(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".feed-fab").click()
|
|
|
|
|
cancel = page.locator("#create-post-modal button:has-text('Cancel')").first
|
|
|
|
|
cancel.click()
|
|
|
|
|
modal = page.locator("#create-post-modal")
|
|
|
|
|
assert not modal.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_pagination_first_page(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
topic = _seed_posts(26)
|
|
|
|
|
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator(".post-card").count() == 25
|
|
|
|
|
assert page.is_visible(".load-more-wrap")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_pagination_load_more(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
topic = _seed_posts(26)
|
|
|
|
|
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
|
|
|
|
|
page.click(".load-more-wrap a")
|
|
|
|
|
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
|
|
|
|
assert f"topic={topic}" in page.url
|
|
|
|
|
assert page.locator(".post-card").count() == 1
|
|
|
|
|
assert not page.is_visible(".load-more-wrap")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_public_access(page, app_server):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Topics")
|
|
|
|
|
assert page.is_visible("text=All")
|
|
|
|
|
assert page.is_visible("text=Login")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_guest_fab_links_to_login(page, app_server):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
fab = page.locator(".feed-fab")
|
|
|
|
|
assert fab.count() == 1
|
|
|
|
|
assert "/auth/login" in (fab.first.get_attribute("href") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_guest_inline_comment_disabled(page, app_server):
|
|
|
|
|
_seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator(".post-card").count() > 0
|
|
|
|
|
form = page.locator(".comment-form.comment-form-guest").first
|
|
|
|
|
form.wait_for(state="attached")
|
|
|
|
|
assert form.locator("textarea").first.is_disabled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_leaderboard_nav_link(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
feat: add alt text extraction from URLs for images and improve CSS variable usage for progress bars
Add `_alt_from_url` helper in Python rendering and `altFromUrl` in JS ContentRenderer to generate descriptive alt attributes from image filenames, replacing empty alt strings. Update image embed templates and Avatar component to use derived alt text. Migrate inline `style.width` assignments to CSS custom properties (`--poll-pct`, `--hud-xp`, `--quest-pct`, `--bar-pct`, `--quota-pct`) across PollManager, GameFarm, and profile/quest CSS for better maintainability. Introduce new admin, docs, projects, services, and base CSS utility classes. Add "Code Farm" docs page entry.
2026-06-23 01:13:48 +02:00
|
|
|
assert page.is_visible("a.topnav-icon[href='/leaderboard']")
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mobile_hamburger_nav(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.set_viewport_size({"width": 390, "height": 844})
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
btn = page.locator("#hamburger-btn")
|
|
|
|
|
btn.wait_for(state="visible")
|
|
|
|
|
btn.click()
|
|
|
|
|
page.locator("#mobile-panel.open").wait_for(state="visible")
|
|
|
|
|
assert "open" in (page.locator("#mobile-panel").get_attribute("class") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_notifications_bell_visible(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
bell = page.locator(".topnav-icon[href='/notifications']")
|
|
|
|
|
assert bell.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_maintenance_mode_blocks_guests(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
try:
|
|
|
|
|
_save_settings(page, maintenance_mode="1", maintenance_message="Down for tests")
|
2026-06-20 01:21:53 +02:00
|
|
|
deadline = time.time() + 5.0
|
2026-06-13 16:32:33 +02:00
|
|
|
response = requests.get(f"{BASE_URL}/feed")
|
2026-06-20 01:21:53 +02:00
|
|
|
while response.status_code != 503 and time.time() < deadline:
|
|
|
|
|
time.sleep(0.2)
|
|
|
|
|
response = requests.get(f"{BASE_URL}/feed")
|
2026-06-13 16:32:33 +02:00
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert "Down for tests" in response.text
|
|
|
|
|
finally:
|
|
|
|
|
_save_settings(
|
|
|
|
|
page, maintenance_mode="0", maintenance_message=DEFAULT_MAINTENANCE_MESSAGE
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_back_to_feed_link(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Back link test")
|
|
|
|
|
back = page.locator("a:has-text('Back to Feed')")
|
|
|
|
|
assert back.is_visible()
|
|
|
|
|
back.click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_own_post(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post to be deleted permanently here")
|
|
|
|
|
post_url = page.url
|
|
|
|
|
page.locator(".post-action-btn:has-text('Delete')").click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
resp = page.goto(post_url, wait_until="domcontentloaded")
|
|
|
|
|
assert resp.status == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_nav_from_topbar(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".topnav-user").click()
|
|
|
|
|
profile_link = page.locator(".dropdown-menu a:has-text('Profile')")
|
|
|
|
|
profile_link.wait_for(state="visible")
|
|
|
|
|
profile_link.click()
|
|
|
|
|
page.wait_for_url(
|
|
|
|
|
f"**/profile/{user['username']}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_guest_has_no_admin_nav(page, app_server):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator("a[href='/admin']").count() == 0
|
|
|
|
|
assert page.locator("a[href='/auth/login']").count() >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_guest_sees_no_role_badges(page, seeded_db):
|
|
|
|
|
_seed_post_role_visibility()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".post-card").first.wait_for(state="visible")
|
|
|
|
|
assert page.locator(".post-author-role").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_guest_action_controls_disabled(page, seeded_db):
|
|
|
|
|
_seed_post_role_visibility()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".post-card").first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
assert card.locator(".vote-up").first.is_disabled()
|
|
|
|
|
fab = page.locator(".feed-fab").first
|
|
|
|
|
assert "/auth/login" in (fab.get_attribute("href") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_member_has_no_admin_nav(bob):
|
|
|
|
|
page, _ = bob
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator("a[href='/admin']").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_member_sees_no_role_badges(bob):
|
|
|
|
|
page, _ = bob
|
|
|
|
|
_seed_post_role_visibility()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".post-card").first.wait_for(state="visible")
|
|
|
|
|
assert page.locator(".post-author-role").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_member_can_act(bob):
|
|
|
|
|
page, _ = bob
|
|
|
|
|
_seed_post_role_visibility()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
fab = page.locator(".feed-fab").first
|
|
|
|
|
fab.wait_for(state="visible")
|
|
|
|
|
assert (fab.get_attribute("data-modal") or "") == "create-post-modal"
|
|
|
|
|
card = page.locator(".post-card").first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
assert not card.locator(".vote-up").first.is_disabled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_admin_has_admin_nav(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator("a[href='/admin']").count() >= 1
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 00:09:34 +02:00
|
|
|
def test_feed_shows_author_level(alice):
|
2026-06-13 16:32:33 +02:00
|
|
|
page, _ = alice
|
|
|
|
|
_seed_post_role_visibility()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".post-card").first.wait_for(state="visible")
|
2026-06-19 00:09:34 +02:00
|
|
|
assert page.locator(".post-author-level").count() >= 1
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_canonical_preserves_pagination(page, app_server):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed?page=2", wait_until="domcontentloaded")
|
|
|
|
|
href = page.locator('link[rel="canonical"]').get_attribute("href")
|
|
|
|
|
assert href.endswith("?page=2"), href
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_canonical_drops_non_pagination_params(page, app_server):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed?sort=new", wait_until="domcontentloaded")
|
|
|
|
|
href = page.locator('link[rel="canonical"]').get_attribute("href")
|
|
|
|
|
assert href.endswith("/feed"), href
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_og_image_is_raster(page, app_server):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
og = page.locator('meta[property="og:image"]').get_attribute("content")
|
|
|
|
|
assert og.endswith(".png"), og
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_pagination_emits_rel_next(page, app_server):
|
|
|
|
|
topic = _seed_feed_posts(26)
|
|
|
|
|
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
|
|
|
|
|
nxt = page.locator('link[rel="next"]')
|
|
|
|
|
assert nxt.count() == 1
|
|
|
|
|
href = nxt.get_attribute("href")
|
|
|
|
|
assert "before=" in href and f"topic={topic}" in href, href
|
2026-07-05 00:08:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_online_now_widget_lists_current_user(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
widget = page.locator(".online-users")
|
|
|
|
|
expect(widget).to_be_visible()
|
|
|
|
|
expect(
|
|
|
|
|
page.locator(f".online-user[title='{user['username']}']")
|
|
|
|
|
).to_have_count(1)
|
|
|
|
|
expect(page.locator(".online-users .presence-dot.online").first).to_be_visible()
|
2026-07-07 15:28:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _scroll_and_open_post(page):
|
|
|
|
|
_seed_feed_posts(30)
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".post-card").first.wait_for(state="visible")
|
|
|
|
|
page.evaluate("window.scrollTo(0, 1200)")
|
|
|
|
|
page.wait_for_function("window.scrollY > 1000")
|
|
|
|
|
card_link = page.locator(".post-card .card-link").nth(5)
|
|
|
|
|
card_link.scroll_into_view_if_needed()
|
|
|
|
|
page.wait_for_timeout(400)
|
|
|
|
|
y_before = page.evaluate("window.scrollY")
|
|
|
|
|
assert y_before > 0
|
|
|
|
|
card_link.click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
|
|
|
|
return y_before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_scroll_restored_via_back_link(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
y_before = _scroll_and_open_post(page)
|
|
|
|
|
back = page.locator("a.back-link")
|
|
|
|
|
back.wait_for(state="visible")
|
|
|
|
|
back.click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed*", wait_until="domcontentloaded")
|
|
|
|
|
page.wait_for_function(f"Math.abs(window.scrollY - {y_before}) < 60")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_scroll_restored_via_browser_back(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
y_before = _scroll_and_open_post(page)
|
|
|
|
|
page.go_back(wait_until="domcontentloaded")
|
|
|
|
|
page.wait_for_function(f"Math.abs(window.scrollY - {y_before}) < 60")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_scroll_not_restored_on_fresh_visit(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
_scroll_and_open_post(page)
|
|
|
|
|
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.wait_for_timeout(600)
|
|
|
|
|
assert page.evaluate("window.scrollY") < 60
|
2026-08-09 11:39:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_post_with_long_code_line(topic="devlog"):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"code_{owner[:8]}",
|
|
|
|
|
"email": f"{owner[:8]}@code.seed",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
marker = f"longcode-{uid[:8]}"
|
|
|
|
|
line = "unbreakable_identifier_" * 10
|
|
|
|
|
get_table("posts").insert(
|
|
|
|
|
{
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(marker, uid),
|
|
|
|
|
"title": marker,
|
|
|
|
|
"content": f"```python\n{line}\n```",
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return marker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_long_code_line_does_not_widen_layout(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
marker = _seed_post_with_long_code_line()
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".post-card").filter(has_text=marker).first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
assert card.locator("pre").count() == 1
|
|
|
|
|
assert_no_horizontal_overflow(page, ".feed-layout")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dashboard_long_code_line_does_not_widen_layout(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
marker = _seed_post_with_long_code_line()
|
|
|
|
|
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".dashboard-post-card").filter(has_text=marker).first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
assert card.locator("pre").count() == 1
|
|
|
|
|
assert_no_horizontal_overflow(page, ".dashboard-main")
|