Implemented. Sign in, then open Experience or Skills to:

- Add, edit, and delete experience entries and project links.
- Manage skill categories and individual skill labels.
- Edit the skills introduction and “How I work” text.
Changes save directly to Supabase—no code edits or Git push needed. Existing content is preserved.
This commit is contained in:
StormRunner06106
2026-09-13 10:03:21 -07:00
parent 6e3779e500
commit 404e0dc16d
25 changed files with 1337 additions and 82 deletions
+135
View File
@@ -0,0 +1,135 @@
"""Browser regression: python -m scripts.test_attachment_reordering (build frontend first)."""
import io
import json
import os
from pathlib import Path
import shutil
import tempfile
import threading
import time
from uuid import uuid4
from PIL import Image
from playwright.sync_api import expect, sync_playwright
import uvicorn
from backend import main
def run():
# A separate server and temporary local storage never change live articles or Dropbox.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for source in main.DATA_DIR.glob("*.json"):
if source.name != "media-deletions.json":
shutil.copyfile(source, root / source.name)
main.DATA_DIR, main.POSTS_PATH, main.UPLOAD_DIR = root, root / "posts.json", root / "uploads"
main.get_supabase = lambda: None
main.read_data.cache_clear()
os.environ.update(JOURNAL_ADMIN_PASSWORD="browser-test", JOURNAL_TOKEN_SECRET="browser-test-secret", JOURNAL_MEDIA_STORAGE="local")
photo = io.BytesIO()
Image.new("RGB", (120, 100), "#537466").save(photo, format="PNG")
media = []
for name in ("First.png", "Second.png", "Third.png"):
uid = uuid4().hex
media.append(main.store_upload(photo.getvalue(), uid, {
"url": "/api/uploads/" + uid, "name": name, "size": len(photo.getvalue()), "media_type": "image/png",
}))
article = {"slug": "reorder-check", "title": "Attachment ordering check", "excerpt": "Checking that the order is saved and displayed consistently.",
"published_at": "2026-09-13", "read_time": 2, "tags": ["AI"], "accent": "mint", "banner": None, "attachments": media,
"content": {"type": "doc", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "This article contains enough text to check reordering attachments and saving the resulting order."}]}]}}
main.POSTS_PATH.write_text(json.dumps([article]), encoding="utf-8")
server = uvicorn.Server(uvicorn.Config(main.app, host="127.0.0.1", port=8011, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(100):
if server.started:
break
time.sleep(.05)
assert server.started
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="msedge", headless=True)
errors = []
def open_editor(page):
page.on("pageerror", lambda error: errors.append(str(error)))
token = main.issue_admin_token()[0]
page.add_init_script("sessionStorage.setItem('alex-journal-admin', " + json.dumps(token) + ");")
page.goto("http://127.0.0.1:8011/blog/reorder-check/edit")
expect(page.get_by_role("button", name="Save changes", exact=True)).to_be_enabled()
page.get_by_role("list", name="Attachment order").scroll_into_view_if_needed()
def names(page, expected):
expect(page.locator(".upload-sortable-list .upload-file-info strong")).to_have_text(expected)
def drag(page, source, target):
start, end = source.bounding_box(), target.bounding_box()
page.mouse.move(start["x"] + start["width"] / 2, start["y"] + start["height"] / 2)
page.mouse.down()
page.mouse.move(end["x"] + end["width"] / 2, end["y"] + end["height"] / 2, steps=15)
page.mouse.up()
page = browser.new_page(viewport={"width": 1280, "height": 1000})
open_editor(page)
expect(page.get_by_role("button", name="Move First.png up", exact=True)).to_have_count(0)
drag(page, page.get_by_role("button", name="Reorder First.png"), page.get_by_role("button", name="Reorder Third.png"))
names(page, ["Second.png", "Third.png", "First.png"])
page.wait_for_timeout(250) # Let the previous drop transition finish.
handle = page.get_by_role("button", name="Reorder First.png")
handle.focus()
page.keyboard.press("Space")
expect(handle).to_have_attribute("aria-pressed", "true")
page.wait_for_timeout(50) # Keyboard sensor attaches its key listener after activation.
page.keyboard.press("ArrowUp")
page.wait_for_timeout(250)
page.keyboard.press("Space")
names(page, ["Second.png", "First.png", "Third.png"])
page.wait_for_timeout(250)
page.get_by_role("button", name="Reorder Second.png").focus()
page.keyboard.press("Space")
page.wait_for_timeout(50)
page.keyboard.press("ArrowDown")
page.keyboard.press("Escape")
names(page, ["Second.png", "First.png", "Third.png"])
drag(page, page.locator(".upload-file-info strong").filter(has_text="Second.png"), page.get_by_role("button", name="Reorder Third.png"))
names(page, ["First.png", "Third.png", "Second.png"])
page.get_by_role("button", name="Save changes", exact=True).click()
expect(page).to_have_url("http://127.0.0.1:8011/blog/reorder-check")
expect(page.locator(".article-attachments figcaption")).to_have_text(["First.png", "Third.png", "Second.png"])
assert [m["name"] for m in json.loads(main.POSTS_PATH.read_text())[0]["attachments"]] == ["First.png", "Third.png", "Second.png"]
print("Mouse handle/row drag, keyboard sorting, Escape cancellation, and saved article order passed.", flush=True)
context = browser.new_context(viewport={"width": 390, "height": 844}, is_mobile=True, has_touch=True, reduced_motion="reduce")
mobile = context.new_page()
open_editor(mobile)
first = mobile.get_by_role("button", name="Reorder First.png").bounding_box()
last = mobile.get_by_role("button", name="Reorder Second.png").bounding_box()
x, start, end = first["x"] + first["width"] / 2, first["y"] + first["height"] / 2, last["y"] + last["height"] / 2
session = context.new_cdp_session(mobile)
session.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x, "y": start}]})
mobile.wait_for_timeout(200)
for step in range(1, 16):
session.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x, "y": start + (end-start)*step/15}]})
mobile.wait_for_timeout(16)
session.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
names(mobile, ["Third.png", "Second.png", "First.png"])
assert mobile.evaluate("document.documentElement.scrollWidth <= innerWidth")
mobile.wait_for_timeout(100) # The touch sensor briefly suppresses ghost clicks after dropping.
mobile.screenshot(path=".venv/reorder-mobile.png")
mobile.get_by_role("button", name="Remove Third.png", exact=True).tap()
names(mobile, ["Second.png", "First.png"])
mobile.get_by_role("button", name="Remove Second.png", exact=True).tap()
names(mobile, ["First.png"])
expect(mobile.get_by_role("button", name="Reorder First.png")).to_be_disabled()
expect(mobile.get_by_role("button", name="Remove First.png", exact=True)).to_be_enabled()
assert not errors, errors
print("Touch drag, mobile layout, reduced motion, and single-file removal passed.", flush=True)
browser.close()
finally:
server.should_exit = True
thread.join(timeout=5)
if __name__ == "__main__":
run()
+138
View File
@@ -0,0 +1,138 @@
"""Browser checks against isolated local data: python -m scripts.test_portfolio_editors."""
import json
import os
from pathlib import Path
import shutil
import tempfile
import threading
import time
import uvicorn
from playwright.sync_api import expect, sync_playwright
from backend import main
def run():
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for source in main.DATA_DIR.glob("*.json"):
if source.name != "media-deletions.json":
shutil.copyfile(source, root / source.name)
main.DATA_DIR = root
main.POSTS_PATH = root / "posts.json"
main.get_supabase = lambda: None
main.read_data.cache_clear()
os.environ.update(JOURNAL_ADMIN_PASSWORD="browser-test", JOURNAL_TOKEN_SECRET="browser-test-secret")
server = uvicorn.Server(uvicorn.Config(main.app, host="127.0.0.1", port=8011, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True); thread.start()
for _ in range(100):
if server.started: break
time.sleep(.05)
assert server.started
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="msedge", headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 1000})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("http://127.0.0.1:8011/experience")
expect(page.locator(".timeline-card").first).to_be_visible()
expect(page.get_by_role("button", name="Add experience", exact=True)).to_have_count(0)
def sign_in():
dialog = page.get_by_role("dialog", name="Admin sign in", exact=True)
dialog.get_by_label("Admin password").fill("browser-test")
dialog.get_by_role("button", name="Sign in", exact=True).click()
expect(dialog).to_have_count(0)
page.get_by_role("button", name="Admin sign in", exact=True).click(); sign_in()
page.get_by_role("button", name="Add experience", exact=True).click()
dialog = page.get_by_role("dialog", name="Add experience", exact=True)
for label, value in [("Role", "Browser engineer"), ("Company", "Example team"), ("Location", "Remote"), ("Start date", "2026-09"), ("Summary", "Deliver useful software and maintain reliable systems.")]:
dialog.get_by_label(label, exact=True).fill(value)
dialog.get_by_label("Highlights", exact=False).fill("First achievement\nSecond achievement")
dialog.get_by_label("Technologies", exact=True).fill("Python")
dialog.get_by_label("Technologies", exact=True).press("Enter")
expect(dialog.get_by_role("button", name="Remove Python", exact=True)).to_be_visible()
dialog.get_by_role("button", name="Add project", exact=True).click()
dialog.get_by_label("Project name", exact=True).fill("Demo")
dialog.get_by_label("Project URL", exact=True).fill("https://example.com")
dialog.get_by_role("button", name="Add experience", exact=True).click()
expect(page.get_by_role("heading", name="Browser engineer", exact=True)).to_be_visible()
page.get_by_role("button", name="Edit Browser engineer at Example team", exact=True).click()
dialog = page.get_by_role("dialog", name="Edit experience", exact=True)
dialog.get_by_label("Role", exact=True).fill("Lead browser engineer")
dialog.get_by_label("I currently work here").uncheck()
dialog.get_by_label("End date", exact=True).fill("2027-01")
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_have_count(0)
page.reload()
expect(page.get_by_role("heading", name="Lead browser engineer", exact=True)).to_be_visible()
page.get_by_role("button", name="Delete Lead browser engineer at Example team", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name="Cancel", exact=True).click()
expect(page.get_by_role("heading", name="Lead browser engineer", exact=True)).to_be_visible()
page.get_by_role("button", name="Delete Lead browser engineer at Example team", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name="Delete entry", exact=True).click()
expect(page.get_by_role("heading", name="Lead browser engineer", exact=True)).to_have_count(0)
print("Experience create, edit, reload persistence, project links, labels, and confirmed deletion passed.", flush=True)
page.goto("http://127.0.0.1:8011/skills")
page.get_by_role("button", name="Add category", exact=True).click()
dialog = page.get_by_role("dialog", name="Add skill category", exact=True)
dialog.get_by_label("Category name", exact=True).fill("Browser tools")
dialog.get_by_label("Description", exact=True).fill("Tools used in a browser check.")
dialog.get_by_label("Skills", exact=True).fill("Python")
dialog.get_by_label("Skills", exact=True).press("Enter")
dialog.get_by_role("button", name="Add category", exact=True).click()
expect(page.get_by_role("heading", name="Browser tools", exact=True)).to_be_visible()
page.get_by_role("button", name="Edit Browser tools", exact=True).click()
dialog = page.get_by_role("dialog", name="Edit skill category", exact=True)
dialog.get_by_role("button", name="Remove Python", exact=True).click()
dialog.get_by_label("Skills", exact=True).fill("TypeScript")
dialog.get_by_label("Skills", exact=True).press("Enter")
dialog.get_by_label("Category name", exact=True).fill("Updated browser tools")
failed = [False]
def expire_once(route):
if route.request.method == "PUT" and not failed[0]:
failed[0] = True
route.fulfill(status=401, content_type="application/json", body=json.dumps({"detail": "Your session ended."}))
else: route.continue_()
page.route("**/api/skills/categories/*", expire_once)
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_contain_text("Your edits are still here")
dialog.get_by_role("button", name="Sign in", exact=True).click(); sign_in()
expect(dialog.get_by_label("Category name", exact=True)).to_have_value("Updated browser tools")
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_have_count(0)
page.reload()
card = page.locator(".skill-card").filter(has=page.get_by_role("heading", name="Updated browser tools", exact=True))
expect(card.locator(".skill-tags")).to_have_text("TypeScript")
page.get_by_role("button", name="Edit overview", exact=True).click()
dialog = page.get_by_role("dialog", name="Edit skills overview", exact=True)
dialog.get_by_label("Introduction", exact=True).fill("A toolkit that evolves with my work.")
dialog.get_by_label("How I work", exact=False).fill("Keep learning\nShip thoughtfully")
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_have_count(0)
page.reload()
expect(page.locator(".principles-list")).to_contain_text("Ship thoughtfully")
page.get_by_role("button", name="Delete Updated browser tools", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name="Delete entry", exact=True).click()
expect(page.get_by_role("heading", name="Updated browser tools", exact=True)).to_have_count(0)
page.set_viewport_size({"width": 390, "height": 844})
page.get_by_role("button", name="Add category", exact=True).click()
page.get_by_role("dialog").screenshot(path=".venv/skills-editor-mobile.png")
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
page.get_by_role("dialog").get_by_role("button", name="Cancel", exact=True).click()
page.get_by_role("button", name="Admin account", exact=True).click()
page.get_by_role("button", name="Sign out", exact=True).click()
expect(page.get_by_role("button", name="Add category", exact=True)).to_have_count(0)
expect(page.locator(".portfolio-item-actions")).to_have_count(0)
assert not errors, errors
print("Skill category/label CRUD, overview editing, session recovery, mobile layout, and public-only controls passed.", flush=True)
browser.close()
finally:
server.should_exit = True; thread.join(timeout=5)
if __name__ == "__main__":
run()