DevPlace CI / test (pull_request) Has been cancelled
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>
454 lines
16 KiB
Python
454 lines
16 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import time
|
|
import requests
|
|
from playwright.sync_api import expect
|
|
from tests.conftest import BASE_URL
|
|
from devplacepy.database import get_table
|
|
_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 test_files_link_on_detail(alice):
|
|
page, _ = alice
|
|
_make_project_ui(page, "UI Files Link")
|
|
link = page.locator(".project-detail-actions a:has-text('Files')")
|
|
expect(link).to_be_visible()
|
|
link.click()
|
|
page.wait_for_url("**/files", wait_until="domcontentloaded")
|
|
expect(page.locator("#pf-tree")).to_be_visible()
|
|
|
|
|
|
def test_files_page_owner_toolbar(alice):
|
|
page, _ = alice
|
|
_make_project_ui(page, "UI Owner Toolbar")
|
|
page.goto(page.url + "/files", wait_until="domcontentloaded")
|
|
for sel in ("#pf-new-file", "#pf-new-folder", "#pf-upload", "#pf-save"):
|
|
expect(page.locator(sel)).to_be_visible()
|
|
|
|
|
|
def test_create_file_via_ui(alice):
|
|
page, _ = alice
|
|
_make_project_ui(page, "UI Create File")
|
|
page.goto(page.url + "/files", wait_until="domcontentloaded")
|
|
_new_file(page, "src/hello.py")
|
|
expect(page.locator(".pf-node-row:has-text('hello.py')")).to_be_visible()
|
|
expect(page.locator(".pf-node-row:has-text('src')")).to_be_visible()
|
|
|
|
|
|
def test_edit_and_save_via_ui(alice):
|
|
page, _ = alice
|
|
proj_url = _make_project_ui(page, "UI Edit Save")
|
|
slug = proj_url.rstrip("/").split("/")[-1]
|
|
page.goto(proj_url + "/files", wait_until="domcontentloaded")
|
|
_new_file(page, "main.py")
|
|
page.locator(".pf-node-row:has-text('main.py')").click()
|
|
page.wait_for_function(
|
|
"window.app && window.app.projectFiles && window.app.projectFiles.currentPath === 'main.py'"
|
|
)
|
|
page.wait_for_timeout(300)
|
|
page.evaluate("window.app.projectFiles.editor.setValue('print(42)')")
|
|
page.click("#pf-save")
|
|
expect(page.locator("#pf-status")).to_have_text("Saved", timeout=4000)
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "main.py").json()["content"] == "print(42)"
|
|
|
|
|
|
def test_create_folder_via_ui(alice):
|
|
page, _ = alice
|
|
_make_project_ui(page, "UI Create Folder")
|
|
page.goto(page.url + "/files", wait_until="domcontentloaded")
|
|
_new_folder(page, "components")
|
|
expect(page.locator(".pf-node-row:has-text('components')")).to_be_visible()
|
|
|
|
|
|
def test_upload_via_ui(alice):
|
|
page, _ = alice
|
|
_make_project_ui(page, "UI Upload")
|
|
page.goto(page.url + "/files", wait_until="domcontentloaded")
|
|
with page.expect_file_chooser() as fc:
|
|
page.click("#pf-upload")
|
|
fc.value.set_files(
|
|
[
|
|
{
|
|
"name": "data.json",
|
|
"mimeType": "application/json",
|
|
"buffer": b'{"a":1}',
|
|
}
|
|
]
|
|
)
|
|
expect(page.locator(".pf-node-row:has-text('data.json')")).to_be_visible()
|
|
|
|
|
|
def test_delete_via_ui(alice):
|
|
page, _ = alice
|
|
_make_project_ui(page, "UI Delete")
|
|
page.goto(page.url + "/files", wait_until="domcontentloaded")
|
|
_new_file(page, "trash.txt")
|
|
page.locator(".pf-node-row:has-text('trash.txt')").click()
|
|
page.click("#pf-delete")
|
|
_dialog_confirm(page)
|
|
expect(page.locator(".pf-node-row:has-text('trash.txt')")).to_have_count(0)
|
|
|
|
|
|
def test_non_owner_readonly_ui(alice, bob):
|
|
alice_page, _ = alice
|
|
proj_url = _make_project_ui(alice_page, "UI Readonly")
|
|
alice_page.goto(proj_url + "/files", wait_until="domcontentloaded")
|
|
_new_file(alice_page, "shared.py")
|
|
expect(alice_page.locator(".pf-node-row:has-text('shared.py')")).to_be_visible()
|
|
|
|
bob_page, _ = bob
|
|
bob_page.goto(proj_url + "/files", wait_until="domcontentloaded")
|
|
expect(bob_page.locator(".pf-node-row:has-text('shared.py')")).to_be_visible()
|
|
expect(bob_page.locator("#pf-new-file")).to_have_count(0)
|
|
expect(bob_page.locator("#pf-save")).to_have_count(0)
|
|
|
|
|
|
def test_add_file_into_selected_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd File")
|
|
_new_folder(page, "src")
|
|
page.locator(".pf-node-row:has-text('src')").click()
|
|
_new_file(page, "main.py")
|
|
expect(page.locator(".pf-node-row:has-text('main.py')")).to_be_visible()
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "src/main.py").status_code == 200
|
|
assert _raw_project_files(slug, "main.py").status_code == 404
|
|
|
|
|
|
def test_add_folder_into_selected_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd Folder")
|
|
_new_folder(page, "src")
|
|
page.locator(".pf-node-row:has-text('src')").click()
|
|
_new_folder(page, "components")
|
|
expect(page.locator(".pf-node-row:has-text('components')")).to_be_visible()
|
|
page.wait_for_timeout(300)
|
|
assert "src/components" in _paths(slug)
|
|
|
|
|
|
def test_upload_into_selected_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd Upload")
|
|
_new_folder(page, "assets")
|
|
page.locator(".pf-node-row:has-text('assets')").click()
|
|
with page.expect_file_chooser() as fc:
|
|
page.click("#pf-upload")
|
|
fc.value.set_files(
|
|
[
|
|
{
|
|
"name": "config.json",
|
|
"mimeType": "application/json",
|
|
"buffer": b'{"k":1}',
|
|
}
|
|
]
|
|
)
|
|
page.wait_for_timeout(400)
|
|
assert _raw_project_files(slug, "assets/config.json").status_code == 200
|
|
|
|
|
|
def test_add_file_into_nested_selected_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd Nested")
|
|
_new_folder(page, "src/api")
|
|
page.locator(".pf-node-row:has-text('api')").click()
|
|
_new_file(page, "routes.py")
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "src/api/routes.py").status_code == 200
|
|
|
|
|
|
def test_new_folder_then_add_file_inside_without_reselect(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd Chained")
|
|
_new_folder(page, "lib")
|
|
_new_file(page, "index.js")
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "lib/index.js").status_code == 200
|
|
|
|
|
|
def test_add_file_uses_parent_of_selected_file(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd Sibling")
|
|
_new_folder(page, "src")
|
|
page.locator(".pf-node-row:has-text('src')").click()
|
|
_new_file(page, "a.py")
|
|
page.locator(".pf-node-row:has-text('a.py')").click()
|
|
_new_file(page, "b.py")
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "src/a.py").status_code == 200
|
|
assert _raw_project_files(slug, "src/b.py").status_code == 200
|
|
|
|
|
|
def test_add_file_at_root_when_nothing_selected(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DirAdd Root")
|
|
_new_file(page, "top.py")
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "top.py").status_code == 200
|
|
|
|
|
|
def test_context_menu_opens_on_right_click(alice):
|
|
page, _ = alice
|
|
_open_files(page, "UI CtxOpen")
|
|
_new_file(page, "a.py")
|
|
_row(page, "a.py").click(button="right")
|
|
menu = page.locator(".context-menu.visible")
|
|
expect(menu).to_be_visible()
|
|
expect(menu).to_contain_text("Rename")
|
|
expect(menu).to_contain_text("Delete")
|
|
|
|
|
|
def test_context_menu_closes_on_outside_click(alice):
|
|
page, _ = alice
|
|
_open_files(page, "UI CtxClose")
|
|
_new_file(page, "a.py")
|
|
_row(page, "a.py").click(button="right")
|
|
expect(page.locator(".context-menu.visible")).to_be_visible()
|
|
page.locator("#pf-current").click()
|
|
expect(page.locator(".context-menu.visible")).to_have_count(0)
|
|
|
|
|
|
def test_context_menu_rename(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI CtxRename")
|
|
_new_file(page, "old.py")
|
|
_row(page, "old.py").click(button="right")
|
|
page.locator(".context-menu.visible .context-menu-item:has-text('Rename')").click()
|
|
_dialog_fill(page, "renamed.py")
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "renamed.py").status_code == 200
|
|
assert _raw_project_files(slug, "old.py").status_code == 404
|
|
|
|
|
|
def test_context_menu_delete_confirm(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI CtxDelete")
|
|
_new_file(page, "gone.py")
|
|
_row(page, "gone.py").click(button="right")
|
|
page.locator(".context-menu.visible .context-menu-item:has-text('Delete')").click()
|
|
_dialog_confirm(page)
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "gone.py").status_code == 404
|
|
|
|
|
|
def test_context_menu_delete_cancel_keeps_file(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI CtxDeleteCancel")
|
|
_new_file(page, "stay.py")
|
|
_row(page, "stay.py").click(button="right")
|
|
page.locator(".context-menu.visible .context-menu-item:has-text('Delete')").click()
|
|
_dialog_cancel(page)
|
|
page.wait_for_timeout(200)
|
|
assert _raw_project_files(slug, "stay.py").status_code == 200
|
|
|
|
|
|
def test_context_menu_new_file_in_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI CtxNewInDir")
|
|
_new_folder(page, "src")
|
|
_row(page, "src").click(button="right")
|
|
page.locator(
|
|
".context-menu.visible .context-menu-item:has-text('New file here')"
|
|
).click()
|
|
_dialog_fill(page, "inside.py")
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "src/inside.py").status_code == 200
|
|
|
|
|
|
def test_multi_select_ctrl_and_delete(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI MultiDelete")
|
|
_new_file(page, "a.py")
|
|
_new_file(page, "b.py")
|
|
_new_file(page, "c.py")
|
|
_row(page, "a.py").click(modifiers=["Control"])
|
|
_row(page, "c.py").click(modifiers=["Control"])
|
|
expect(page.locator(".pf-node-row.pf-selected")).to_have_count(2)
|
|
_row(page, "c.py").click(button="right")
|
|
page.locator(
|
|
".context-menu.visible .context-menu-item:has-text('Delete 2 items')"
|
|
).click()
|
|
_dialog_confirm(page)
|
|
page.wait_for_timeout(400)
|
|
assert _raw_project_files(slug, "a.py").status_code == 404
|
|
assert _raw_project_files(slug, "c.py").status_code == 404
|
|
assert _raw_project_files(slug, "b.py").status_code == 200
|
|
|
|
|
|
def test_shift_select_range(alice):
|
|
page, _ = alice
|
|
_open_files(page, "UI ShiftRange")
|
|
_new_file(page, "a.py")
|
|
_new_file(page, "b.py")
|
|
_new_file(page, "c.py")
|
|
_row(page, "a.py").click()
|
|
_row(page, "c.py").click(modifiers=["Shift"])
|
|
expect(page.locator(".pf-node-row.pf-selected")).to_have_count(3)
|
|
|
|
|
|
def test_drag_file_into_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DragIntoDir")
|
|
_new_folder(page, "src")
|
|
_new_file(page, "move.py")
|
|
_row(page, "move.py").drag_to(_row(page, "src"))
|
|
page.wait_for_timeout(400)
|
|
assert _raw_project_files(slug, "src/move.py").status_code == 200
|
|
assert _raw_project_files(slug, "move.py").status_code == 404
|
|
|
|
|
|
def test_drag_file_to_root(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DragToRoot")
|
|
_new_folder(page, "src")
|
|
_row(page, "src").click()
|
|
_new_file(page, "deep.py")
|
|
page.wait_for_timeout(200)
|
|
assert _raw_project_files(slug, "src/deep.py").status_code == 200
|
|
_row(page, "deep.py").drag_to(page.locator(".pf-root-row"))
|
|
page.wait_for_timeout(400)
|
|
assert _raw_project_files(slug, "deep.py").status_code == 200
|
|
assert _raw_project_files(slug, "src/deep.py").status_code == 404
|
|
|
|
|
|
def test_drag_multi_selection_into_directory(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI DragMulti")
|
|
_new_folder(page, "dest")
|
|
_new_file(page, "x.py")
|
|
_new_file(page, "y.py")
|
|
_row(page, "x.py").click(modifiers=["Control"])
|
|
_row(page, "y.py").click(modifiers=["Control"])
|
|
_row(page, "x.py").drag_to(_row(page, "dest"))
|
|
page.wait_for_timeout(500)
|
|
assert _raw_project_files(slug, "dest/x.py").status_code == 200
|
|
assert _raw_project_files(slug, "dest/y.py").status_code == 200
|
|
|
|
|
|
def test_toolbar_delete_uses_custom_dialog(alice):
|
|
page, _ = alice
|
|
slug = _open_files(page, "UI ToolbarDelete")
|
|
_new_file(page, "t.py")
|
|
_row(page, "t.py").click()
|
|
page.click("#pf-delete")
|
|
expect(page.locator(".dialog-overlay.visible")).to_be_visible()
|
|
_dialog_confirm(page)
|
|
page.wait_for_timeout(300)
|
|
assert _raw_project_files(slug, "t.py").status_code == 404
|
|
|
|
|
|
def test_empty_project_shows_hint(alice):
|
|
page, _ = alice
|
|
_open_files(page, "EmptyHint")
|
|
expect(page.locator("#pf-empty")).to_be_visible()
|
|
expect(page.locator("#pf-editor-wrap")).to_be_hidden()
|
|
expect(page.locator(".pf-empty-msg")).to_contain_text("Create a file")
|