Files
devplacepy/tests/e2e/project/files.py
T
blindxfishandClaude Fable 5 72e088c160
DevPlace CI / test (pull_request) Has been cancelled
Dedicate the project page to the project
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>
2026-08-10 23:00:12 +02:00

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")