forked from retoor/devplacepy
The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.
An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1029 lines
39 KiB
Python
1029 lines
39 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import re
|
|
from uuid import uuid4
|
|
from datetime import datetime, timedelta, timezone
|
|
from playwright.sync_api import expect
|
|
from tests.conftest import BASE_URL, assert_share_copies
|
|
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}",
|
|
"terms_version": "1",
|
|
"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]}",
|
|
"terms_version": "1",
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"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]}",
|
|
"terms_version": "1",
|
|
"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
|
|
import time
|
|
import requests
|
|
from tests.conftest import BASE_URL
|
|
_counter_project_files = [0]
|
|
def _signup_project_files():
|
|
_counter_project_files[0] += 1
|
|
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
key = get_table("users").find_one(username=name)["api_key"]
|
|
return name, key
|
|
def _h_project_files(key):
|
|
return {"X-API-KEY": key, "Accept": "application/json"}
|
|
def _create_project_project_files(key, title):
|
|
r = requests.post(
|
|
f"{BASE_URL}/projects/create",
|
|
headers=_h_project_files(key),
|
|
data={
|
|
"title": title,
|
|
"description": "filesystem test",
|
|
"project_type": "software",
|
|
"status": "In Development",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["data"]
|
|
def _write_project_files(key, slug, path, content):
|
|
return requests.post(
|
|
f"{BASE_URL}/projects/{slug}/files/write",
|
|
headers=_h_project_files(key),
|
|
data={"path": path, "content": content},
|
|
allow_redirects=False,
|
|
)
|
|
def _mkdir(key, slug, path):
|
|
return requests.post(
|
|
f"{BASE_URL}/projects/{slug}/files/mkdir",
|
|
headers=_h_project_files(key),
|
|
data={"path": path},
|
|
allow_redirects=False,
|
|
)
|
|
def _move(key, slug, from_path, to_path):
|
|
return requests.post(
|
|
f"{BASE_URL}/projects/{slug}/files/move",
|
|
headers=_h_project_files(key),
|
|
data={"from_path": from_path, "to_path": to_path},
|
|
allow_redirects=False,
|
|
)
|
|
def _delete(key, slug, path):
|
|
return requests.post(
|
|
f"{BASE_URL}/projects/{slug}/files/delete",
|
|
headers=_h_project_files(key),
|
|
data={"path": path},
|
|
allow_redirects=False,
|
|
)
|
|
def _list(slug, key=None):
|
|
return requests.get(
|
|
f"{BASE_URL}/projects/{slug}/files",
|
|
headers=_h_project_files(key) if key else {"Accept": "application/json"},
|
|
)
|
|
def _raw_project_files(slug, path, key=None):
|
|
return requests.get(
|
|
f"{BASE_URL}/projects/{slug}/files/raw",
|
|
params={"path": path},
|
|
headers=_h_project_files(key) if key else {"Accept": "application/json"},
|
|
)
|
|
def _paths(slug, key=None):
|
|
return sorted(f["path"] for f in _list(slug, key).json()["files"])
|
|
def _make_project_ui(page, title):
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", title)
|
|
page.fill("#description", "Project for filesystem UI test")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
return page.url
|
|
def _open_files(page, title):
|
|
proj_url = _make_project_ui(page, title)
|
|
slug = proj_url.rstrip("/").split("/")[-1]
|
|
page.goto(proj_url + "/files", wait_until="domcontentloaded")
|
|
return slug
|
|
def _dialog_fill(page, value):
|
|
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
|
|
page.fill(".dialog-overlay.visible .dialog-input", value)
|
|
page.click(".dialog-overlay.visible .dialog-confirm")
|
|
def _dialog_confirm(page):
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
|
|
page.click(".dialog-overlay.visible .dialog-confirm")
|
|
def _dialog_cancel(page):
|
|
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
|
|
page.click(".dialog-overlay.visible .dialog-cancel")
|
|
def _new_folder(page, name):
|
|
page.click("#pf-new-folder")
|
|
_dialog_fill(page, name)
|
|
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
|
|
def _new_file(page, name):
|
|
page.click("#pf-new-file")
|
|
_dialog_fill(page, name)
|
|
def _row(page, name):
|
|
return page.locator(f".pf-node-row:has-text('{name}')").first
|
|
def _alice_key():
|
|
return get_table("users").find_one(username="alice_test")["api_key"]
|
|
def _seed_projects(count):
|
|
owner = str(uuid4())
|
|
get_table("users").insert(
|
|
{
|
|
"uid": owner,
|
|
"username": f"pag_{owner[:8]}",
|
|
"terms_version": "1",
|
|
"email": f"{owner[:8]}@test.devplace",
|
|
"password_hash": "x",
|
|
"role": "Member",
|
|
"is_active": True,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
projects = get_table("projects")
|
|
for i in range(count):
|
|
uid = str(uuid4())
|
|
title = f"Pag Project {i}"
|
|
projects.insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": make_combined_slug(title, uid),
|
|
"title": title,
|
|
"description": "paginated",
|
|
"project_type": "software",
|
|
"status": "In Development",
|
|
"stars": 0,
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
}
|
|
)
|
|
return owner
|
|
def _create_project_projects(page, title):
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", title)
|
|
page.fill("#description", "Project for testing")
|
|
page.fill("#release_date", "01/06/2026")
|
|
page.check("input[value='game']")
|
|
page.locator("#platforms-input").fill("PC")
|
|
page.locator("#platforms-input").press("Enter")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
def _open_project_with_delete(page, title):
|
|
proj_url = _make_project_ui(page, title)
|
|
slug = proj_url.rstrip("/").split("/")[-1]
|
|
return proj_url, slug
|
|
def _click_delete(page):
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Delete')").click()
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from pathlib import Path
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
from devplacepy.services.jobs import queue
|
|
from devplacepy.services.jobs.zip_service import ZipService
|
|
from tests.conftest import run_async
|
|
_counter_zip_download = [0]
|
|
def _signup_zip_download():
|
|
_counter_zip_download[0] += 1
|
|
name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}"
|
|
session = requests.Session()
|
|
session.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
refresh_snapshot()
|
|
key = get_table("users").find_one(username=name)["api_key"]
|
|
return name, key
|
|
def _h_zip_download(key):
|
|
return {"X-API-KEY": key, "Accept": "application/json"}
|
|
def _create_project_zip_download(key, title):
|
|
r = requests.post(
|
|
f"{BASE_URL}/projects/create",
|
|
headers=_h_zip_download(key),
|
|
data={
|
|
"title": title,
|
|
"description": "zip download test",
|
|
"project_type": "software",
|
|
"status": "In Development",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["data"]
|
|
def _write_zip_download(key, slug, path, content):
|
|
r = requests.post(
|
|
f"{BASE_URL}/projects/{slug}/files/write",
|
|
headers=_h_zip_download(key),
|
|
data={"path": path, "content": content},
|
|
allow_redirects=False,
|
|
)
|
|
assert r.status_code in (200, 302), r.text
|
|
def _process_uid(uid):
|
|
async def drive():
|
|
svc = ZipService()
|
|
for _ in range(400):
|
|
await svc.run_once()
|
|
refresh_snapshot()
|
|
job = queue.get_job(uid)
|
|
if job and job["status"] in ("done", "failed"):
|
|
return
|
|
await asyncio.sleep(0.05)
|
|
|
|
run_async(drive())
|
|
def _drain_pending():
|
|
async def drive():
|
|
svc = ZipService()
|
|
appeared = False
|
|
for _ in range(600):
|
|
refresh_snapshot()
|
|
pending = [
|
|
r
|
|
for r in get_table("jobs").find(kind="zip")
|
|
if r["status"] in ("pending", "running")
|
|
]
|
|
if pending:
|
|
appeared = True
|
|
await svc.run_once()
|
|
refresh_snapshot()
|
|
pending = [
|
|
r
|
|
for r in get_table("jobs").find(kind="zip")
|
|
if r["status"] in ("pending", "running")
|
|
]
|
|
if appeared and not pending and not svc._inflight:
|
|
return
|
|
await asyncio.sleep(0.05)
|
|
|
|
run_async(drive())
|
|
def _cleanup_archives():
|
|
refresh_snapshot()
|
|
jobs = get_table("jobs")
|
|
for row in list(jobs.find(kind="zip")):
|
|
result = json.loads(row.get("result") or "{}")
|
|
local_path = result.get("local_path")
|
|
if local_path:
|
|
Path(local_path).unlink(missing_ok=True)
|
|
jobs.delete(uid=row["uid"])
|
|
def _login_zip_download(page, name):
|
|
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
|
|
page.fill("#email", f"{name}@t.dev")
|
|
page.fill("#password", "secret123")
|
|
page.click("button:has-text('Sign in')")
|
|
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
|
|
|
|
|
def test_feed_topnav_navigation(alice):
|
|
page, user = alice
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
page.click("a:has-text('Projects')")
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.click("a:has-text('Home')")
|
|
page.wait_for_url(f"{BASE_URL}/", wait_until="domcontentloaded")
|
|
|
|
|
|
def test_auto_open_file_on_load(alice):
|
|
page, _ = alice
|
|
key = _alice_key()
|
|
project = _create_project_project_files(key, "AutoOpen")
|
|
_write_project_files(key, project["slug"], "main.py", "print('hi')")
|
|
page.goto(
|
|
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
|
|
)
|
|
expect(page.locator("#pf-editor-wrap")).to_be_visible()
|
|
expect(page.locator("#pf-empty")).to_be_hidden()
|
|
|
|
|
|
def test_readme_preferred_on_load(alice):
|
|
page, _ = alice
|
|
key = _alice_key()
|
|
project = _create_project_project_files(key, "ReadmePref")
|
|
_write_project_files(key, project["slug"], "main.py", "x = 1")
|
|
_write_project_files(key, project["slug"], "README.md", "# Hello Readme")
|
|
page.goto(
|
|
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
|
|
)
|
|
page.wait_for_function(
|
|
"window.app && window.app.projectFiles && window.app.projectFiles.editor"
|
|
)
|
|
page.wait_for_timeout(300)
|
|
content = page.evaluate("window.app.projectFiles.editor.getValue()")
|
|
assert "Hello Readme" in content
|
|
|
|
|
|
def test_folder_click_keeps_editor(alice):
|
|
page, _ = alice
|
|
key = _alice_key()
|
|
project = _create_project_project_files(key, "FolderKeepsEditor")
|
|
_write_project_files(key, project["slug"], "src/a.py", "x = 1")
|
|
page.goto(
|
|
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
|
|
)
|
|
expect(page.locator("#pf-editor-wrap")).to_be_visible()
|
|
_row(page, "src").click()
|
|
expect(page.locator("#pf-editor-wrap")).to_be_visible()
|
|
expect(page.locator("#pf-empty")).to_be_hidden()
|
|
|
|
|
|
def test_project_vote(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Votable Project")
|
|
star = "form[action*='/votes/project/'] button"
|
|
count = "form[action*='/votes/project/'] .vote-count-value"
|
|
before = int(page.locator(star).first.inner_text().strip("☆ "))
|
|
page.locator(star).first.click()
|
|
expect(page.locator(count).first).to_have_text(str(before + 1))
|
|
after = int(page.locator(star).first.inner_text().strip("☆ "))
|
|
assert after == before + 1
|
|
|
|
|
|
def test_project_voted_state_persists(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Voted State Project")
|
|
star = "form[action*='/votes/project/'] button"
|
|
page.locator(star).first.click()
|
|
page.wait_for_timeout(500)
|
|
page.reload(wait_until="domcontentloaded")
|
|
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
|
|
|
|
|
|
def test_delete_own_project(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Deletable Project XYZ")
|
|
proj_url = page.url
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Delete')").click()
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
resp = page.goto(proj_url, wait_until="domcontentloaded")
|
|
assert resp.status == 404
|
|
|
|
|
|
def test_project_edit_button(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Editable Project ABC")
|
|
page.locator(".project-actions-more").click()
|
|
expect(page.locator(".context-menu-item:has-text('Edit')")).to_be_visible()
|
|
|
|
|
|
def test_project_edit_modal_prefills(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Edit Modal Project")
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Edit')").click()
|
|
assert page.is_visible("h3:has-text('Edit Project')")
|
|
expect(page.locator("#edit-project-title")).to_have_value("Edit Modal Project")
|
|
expect(page.locator("#platforms-tags .platform-tag:has-text('PC')")).to_be_visible()
|
|
|
|
|
|
def test_project_edit_submit(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Original Project Title")
|
|
proj_url = page.url
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Edit')").click()
|
|
page.fill("#edit-project-title", "Edited Project Title")
|
|
page.fill("#edit-project-description", "Edited project description body")
|
|
page.click("button:has-text('Save Changes')")
|
|
expect(
|
|
page.locator(".project-detail-title:has-text('Edited Project Title')")
|
|
).to_be_visible()
|
|
assert page.url.rstrip("/") == proj_url.rstrip("/")
|
|
|
|
|
|
def test_project_edit_status_change(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Status Change Project")
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Edit')").click()
|
|
page.check("#edit-project-modal input[value='Released']")
|
|
page.click("button:has-text('Save Changes')")
|
|
expect(page.locator(".project-status:has-text('Released')")).to_be_visible()
|
|
|
|
|
|
def test_project_edit_hidden_for_non_owner(alice, bob):
|
|
page, _ = alice
|
|
_create_project_projects(page, "Owner Only Edit Project")
|
|
proj_url = page.url
|
|
bob_page, _ = bob
|
|
bob_page.goto(proj_url, wait_until="domcontentloaded")
|
|
bob_page.locator(".project-actions-more").click()
|
|
expect(
|
|
bob_page.locator(".context-menu-item:has-text('Download zip')")
|
|
).to_be_visible()
|
|
expect(bob_page.locator(".context-menu-item:has-text('Edit')")).to_have_count(0)
|
|
|
|
|
|
def test_project_detail_share_button(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "Share Project")
|
|
page.fill("#description", "A project created for the share button test")
|
|
page.fill("#release_date", "01/06/2026")
|
|
page.check("input[value='game']")
|
|
page.locator("#platforms-input").fill("PC")
|
|
page.locator("#platforms-input").press("Enter")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
assert_share_copies(page, "/projects/")
|
|
|
|
|
|
def test_projects_page_loads(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
assert page.is_visible("h1:has-text('Projects')")
|
|
assert page.is_visible("text=Discover amazing projects")
|
|
|
|
|
|
def test_projects_search_bar(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
search = page.locator("input[placeholder='Search projects...']")
|
|
assert search.is_visible()
|
|
|
|
|
|
def test_projects_tabs(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
tabs = ["Recently Released", "Most Popular", "New This Week"]
|
|
for tab in tabs:
|
|
assert page.is_visible(f"text={tab}")
|
|
|
|
|
|
def test_projects_tab_switch(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.click("a:has-text('Most Popular')")
|
|
page.wait_for_url(f"{BASE_URL}/projects?tab=popular", wait_until="domcontentloaded")
|
|
page.click("a:has-text('New This Week')")
|
|
page.wait_for_url(f"{BASE_URL}/projects?tab=new", wait_until="domcontentloaded")
|
|
|
|
|
|
def test_create_project_button(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
fab = page.locator("#create-project-btn")
|
|
assert fab.is_visible()
|
|
fab.click()
|
|
assert page.is_visible("h3:has-text('Create Project')")
|
|
|
|
|
|
def test_create_project_full(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "Playwright Test Game")
|
|
page.fill("#description", "A game created by Playwright integration tests")
|
|
page.fill("#release_date", "01/06/2026")
|
|
page.check("input[value='game']")
|
|
page.locator("#platforms-input").fill("PC")
|
|
page.locator("#platforms-input").press("Enter")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
assert page.is_visible("text=Playwright Test Game")
|
|
|
|
|
|
def test_create_project_software(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "CLI Tool")
|
|
page.fill("#description", "A command line tool")
|
|
page.check("input[value='software']")
|
|
page.check("input[value='Released']")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
assert page.is_visible("text=CLI Tool")
|
|
|
|
|
|
def test_create_project_mobile_app(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "Mobile Messenger")
|
|
page.fill("#description", "A cross-platform messaging app")
|
|
page.check("input[value='mobile_app']")
|
|
page.locator("#platforms-input").fill("iOS")
|
|
page.locator("#platforms-input").press("Enter")
|
|
page.locator("#platforms-input").fill("Android")
|
|
page.locator("#platforms-input").press("Enter")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
assert page.is_visible("text=Mobile Messenger")
|
|
|
|
|
|
def test_project_search(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "SearchableProject")
|
|
page.fill("#description", "Find me via search")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.fill("input[placeholder='Search projects...']", "SearchableProject")
|
|
page.locator("input[placeholder='Search projects...']").press("Enter")
|
|
page.wait_for_timeout(500)
|
|
assert page.is_visible("text=SearchableProject")
|
|
|
|
|
|
def test_project_cancel_modal(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
cancel = page.locator("button:has-text('Cancel')").first
|
|
cancel.click()
|
|
modal = page.locator("#create-project-modal")
|
|
assert not modal.is_visible()
|
|
|
|
|
|
def test_project_platform_presets(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
presets = page.locator(".platform-preset")
|
|
assert presets.count() >= 5
|
|
presets.first.click()
|
|
tag = page.locator("#platforms-tags .platform-tag")
|
|
assert tag.is_visible()
|
|
|
|
|
|
def test_project_pagination_first_page(alice):
|
|
page, _ = alice
|
|
owner = _seed_projects(26)
|
|
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
|
assert page.locator(".project-card").count() == 25
|
|
assert page.is_visible(".load-more-wrap")
|
|
|
|
|
|
def test_project_pagination_load_more(alice):
|
|
page, _ = alice
|
|
owner = _seed_projects(26)
|
|
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
|
page.click(".load-more-wrap a")
|
|
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
|
assert f"user_uid={owner}" in page.url
|
|
assert page.locator(".project-card").count() == 1
|
|
assert not page.is_visible(".load-more-wrap")
|
|
|
|
|
|
def test_project_pagination_count_reflects_total(alice):
|
|
page, _ = alice
|
|
owner = _seed_projects(26)
|
|
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
|
count_text = page.text_content(".projects-count")
|
|
assert "Showing 25 of 26 projects" in count_text
|
|
|
|
|
|
def test_projects_count(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
count_text = page.text_content(".projects-count")
|
|
assert "Showing" in count_text
|
|
assert "projects" in count_text
|
|
|
|
|
|
def test_project_detail_page(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "DetailTestProject")
|
|
page.fill("#description", "Project detail page test")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
assert page.is_visible("text=DetailTestProject")
|
|
assert page.is_visible("text=Back to Projects")
|
|
|
|
|
|
def test_project_detail_unauth(page):
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
assert page.is_visible("h1:has-text('Projects')")
|
|
assert page.locator("#create-project-btn").count() == 0
|
|
|
|
|
|
def test_project_detail_back_link(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "BackLinkProject")
|
|
page.fill("#description", "Test back link")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
back = page.locator("a:has-text('Back to Projects')")
|
|
assert back.is_visible()
|
|
back.click()
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
|
def test_project_comments_form_visible(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "CommentProject")
|
|
page.fill("#description", "Project for testing comments")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
assert page.is_visible("text=Comments")
|
|
assert page.is_visible("text=No comments yet")
|
|
assert page.is_visible(".comment-form textarea[name='content']")
|
|
|
|
|
|
def test_project_comment_create(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "CommentCreateProj")
|
|
page.fill("#description", "Project for creating a comment")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
page.fill(".comment-form textarea[name='content']", "Great project!")
|
|
page.click(".comment-form button:has-text('Post')")
|
|
page.wait_for_timeout(500)
|
|
assert page.is_visible("text=Great project!")
|
|
|
|
|
|
def test_project_comment_reply(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "CommentReplyProj")
|
|
page.fill("#description", "Project for testing reply")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
page.fill(".comment-form textarea[name='content']", "First comment")
|
|
page.click(".comment-form button:has-text('Post')")
|
|
page.wait_for_timeout(500)
|
|
assert page.is_visible("text=First comment")
|
|
page.click("button:has-text('Reply')")
|
|
reply_form = page.locator(".comment-reply-form").first
|
|
reply_form.locator("textarea[name='content']").fill("Reply to comment")
|
|
reply_form.locator("button:has-text('Post')").click()
|
|
page.wait_for_timeout(500)
|
|
assert page.is_visible("text=Reply to comment")
|
|
|
|
|
|
def test_project_comment_delete(alice):
|
|
page, _ = alice
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", "CommentDeleteProj")
|
|
page.fill("#description", "Project for testing delete")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
page.fill(".comment-form textarea[name='content']", "Comment to delete")
|
|
page.click(".comment-form button:has-text('Post')")
|
|
page.wait_for_timeout(500)
|
|
assert page.is_visible("text=Comment to delete")
|
|
page.locator(".comment-action-btn:has-text('Delete')").click()
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
page.wait_for_timeout(500)
|
|
assert not page.is_visible("text=Comment to delete")
|
|
|
|
|
|
def test_project_make_private(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "PrivateToggleProject")
|
|
slug = page.url.rstrip("/").split("/")[-1]
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Make private')").click()
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
page.wait_for_url(f"**/projects/{slug}", wait_until="domcontentloaded")
|
|
expect(page.locator(".badge-type:has-text('Private')")).to_be_visible()
|
|
|
|
|
|
def test_project_make_readonly(alice):
|
|
page, _ = alice
|
|
_create_project_projects(page, "ReadonlyToggleProject")
|
|
slug = page.url.rstrip("/").split("/")[-1]
|
|
page.locator(".project-actions-more").click()
|
|
page.locator(".context-menu-item:has-text('Make read-only')").click()
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
page.wait_for_url(f"**/projects/{slug}", wait_until="domcontentloaded")
|
|
expect(page.locator(".badge-type:has-text('Read-only')")).to_be_visible()
|
|
|
|
|
|
def test_data_confirm_accept_proceeds(alice):
|
|
page, _ = alice
|
|
proj_url, _ = _open_project_with_delete(page, "Confirm Accept")
|
|
_click_delete(page)
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
resp = page.goto(proj_url, wait_until="domcontentloaded")
|
|
assert resp.status == 404
|
|
|
|
|
|
def test_detail_download_button_downloads(app_server, page):
|
|
try:
|
|
name, key = _signup_zip_download()
|
|
proj = _create_project_zip_download(key, "Detail Download UI")
|
|
slug = proj["slug"] or proj["uid"]
|
|
_write_zip_download(key, slug, "README.md", "# hi")
|
|
_login_zip_download(page, name)
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
page.locator(".project-actions-more").click()
|
|
btn = page.locator(".context-menu-item:has-text('Download zip')")
|
|
btn.wait_for(state="visible")
|
|
with page.expect_download(timeout=30000) as dl_info:
|
|
btn.click()
|
|
_drain_pending()
|
|
assert dl_info.value.suggested_filename.endswith(".zip")
|
|
finally:
|
|
_cleanup_archives()
|
|
|
|
|
|
def _seed_project_with_comments():
|
|
owner = str(uuid4())
|
|
get_table("users").insert(
|
|
{
|
|
"uid": owner,
|
|
"username": f"pcseed_{owner[:8]}",
|
|
"terms_version": "1",
|
|
"email": f"{owner[:8]}@pc.seed",
|
|
"password_hash": "x",
|
|
"role": "Member",
|
|
"is_active": True,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
uid = str(uuid4())
|
|
marker = f"projcom-{uid[:8]}"
|
|
get_table("projects").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": make_combined_slug(marker, uid),
|
|
"title": marker,
|
|
"description": "Project with comment hierarchy.",
|
|
"project_type": "software",
|
|
"platforms": "Linux",
|
|
"status": "Released",
|
|
"stars": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
parent = str(uuid4())
|
|
base = datetime.now(timezone.utc)
|
|
rows = [
|
|
(f"{marker}-excluded", str(uuid4()), None, -2),
|
|
(f"{marker}-flat", str(uuid4()), None, -1),
|
|
(f"{marker}-parent", parent, None, 0),
|
|
(f"{marker}-reply", str(uuid4()), parent, 1),
|
|
]
|
|
comments = get_table("comments")
|
|
for content, cuid, par, off in rows:
|
|
comments.insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": cuid,
|
|
"target_type": "project",
|
|
"target_uid": uid,
|
|
"user_uid": owner,
|
|
"content": content,
|
|
"parent_uid": par,
|
|
"created_at": (base + timedelta(seconds=off)).isoformat(),
|
|
}
|
|
)
|
|
return marker
|
|
|
|
|
|
def test_projects_list_preserves_comment_hierarchy(page, app_server):
|
|
marker = _seed_project_with_comments()
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
card = page.locator(".project-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(card.locator(".post-card-comments")).not_to_contain_text(f"{marker}-excluded")
|
|
nested = card.locator(".post-card-comments .comment-replies .comment-text")
|
|
expect(nested).to_contain_text(f"{marker}-reply")
|
|
|
|
|
|
import time as _time_containers_menu
|
|
import requests as _requests_containers_menu
|
|
from tests.conftest import login_user
|
|
from devplacepy.database import get_primary_admin_uid, invalidate_admins_cache
|
|
from devplacepy.utils import clear_user_cache
|
|
|
|
|
|
_counter_containers_menu = [0]
|
|
|
|
|
|
def _signup_containers_menu(prefix):
|
|
_counter_containers_menu[0] += 1
|
|
name = f"{prefix}{int(_time_containers_menu.time() * 1000)}{_counter_containers_menu[0]}"
|
|
_requests_containers_menu.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
row = get_table("users").find_one(username=name)
|
|
return name, row["uid"], row["api_key"]
|
|
|
|
|
|
def _make_admin_containers_menu(prefix):
|
|
name, uid, key = _signup_containers_menu(prefix)
|
|
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
|
|
clear_user_cache(uid)
|
|
invalidate_admins_cache()
|
|
return name, uid, key
|
|
|
|
|
|
def _create_project_containers_menu(key, title, is_private=False):
|
|
data = {
|
|
"title": title,
|
|
"description": "containers menu gating test",
|
|
"project_type": "software",
|
|
"status": "In Development",
|
|
}
|
|
if is_private:
|
|
data["is_private"] = "on"
|
|
r = _requests_containers_menu.post(
|
|
f"{BASE_URL}/projects/create",
|
|
headers={"Accept": "application/json", "X-API-KEY": key},
|
|
data=data,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["data"]["slug"]
|
|
|
|
|
|
def test_containers_menu_visible_for_admin_on_own_private_project(page, app_server):
|
|
name, uid, key = _make_admin_containers_menu("cmown")
|
|
slug = _create_project_containers_menu(key, "Containers Menu Own Private", is_private=True)
|
|
login_user(page, {"email": f"{name}@t.dev", "password": "secret123"})
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
page.locator(".project-actions-more").click()
|
|
expect(page.locator(".context-menu-item:has-text('Containers')")).to_be_visible()
|
|
|
|
|
|
def test_containers_menu_visible_for_any_admin_on_public_project(page, app_server):
|
|
_, _, owner_key = _make_admin_containers_menu("cmpubowner")
|
|
other_name, _, _ = _make_admin_containers_menu("cmpubother")
|
|
slug = _create_project_containers_menu(owner_key, "Containers Menu Public", is_private=False)
|
|
login_user(page, {"email": f"{other_name}@t.dev", "password": "secret123"})
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
page.locator(".project-actions-more").click()
|
|
expect(page.locator(".context-menu-item:has-text('Containers')")).to_be_visible()
|
|
|
|
|
|
def test_containers_menu_hidden_for_non_owner_admin_on_member_private_project(page, app_server):
|
|
_, member_uid, member_key = _signup_containers_menu("cmmember")
|
|
slug = _create_project_containers_menu(member_key, "Containers Menu Member Private", is_private=True)
|
|
admin_name, admin_uid, _ = _make_admin_containers_menu("cmviewer")
|
|
assert get_primary_admin_uid() != admin_uid
|
|
login_user(page, {"email": f"{admin_name}@t.dev", "password": "secret123"})
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
page.locator(".project-actions-more").click()
|
|
expect(page.locator(".context-menu-item:has-text('Containers')")).to_have_count(0)
|