You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
399 lines
19 KiB
399 lines
19 KiB
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import base64
|
|
from pathlib import Path
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
OUTPUT_DIR = ROOT / "output" / "playwright"
|
|
RUNTIME_DIR = OUTPUT_DIR / "runtime" / str(os.getpid())
|
|
CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
|
|
|
|
def main() -> int:
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
if RUNTIME_DIR.exists():
|
|
shutil.rmtree(RUNTIME_DIR)
|
|
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
ensure_frontend_built()
|
|
|
|
port = find_free_port()
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
runtime_db = RUNTIME_DIR / "ui-playwright.db"
|
|
prepare_database(runtime_db)
|
|
process = start_server(port, runtime_db)
|
|
|
|
try:
|
|
wait_for_server(base_url)
|
|
run_browser_flow(base_url)
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
|
|
print(f"Playwright smoke flow passed. Screenshots saved to {OUTPUT_DIR}")
|
|
return 0
|
|
|
|
|
|
def ensure_frontend_built() -> None:
|
|
dist_index = ROOT / "app" / "web" / "dist" / "index.html"
|
|
if dist_index.exists():
|
|
return
|
|
subprocess.run(["npm", "run", "build"], cwd=ROOT, check=True)
|
|
|
|
|
|
def find_free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def prepare_database(runtime_db: Path) -> None:
|
|
config = Config(str(ROOT / "alembic.ini"))
|
|
config.set_main_option("sqlalchemy.url", f"sqlite+aiosqlite:///{runtime_db}")
|
|
command.upgrade(config, "head")
|
|
|
|
|
|
def start_server(port: int, runtime_db: Path) -> subprocess.Popen[str]:
|
|
env = os.environ.copy()
|
|
env["IRON_DATABASE_URL"] = f"sqlite+aiosqlite:///{runtime_db}"
|
|
env["IRON_UPLOAD_TEMP_DIR"] = str(RUNTIME_DIR / "uploads")
|
|
env["IRON_LOCAL_STORAGE_DIR"] = str(RUNTIME_DIR / "storage")
|
|
env["IRON_CACHE_DIR"] = str(RUNTIME_DIR / "cache")
|
|
env["IRON_BOOTSTRAP_USERNAME"] = "admin"
|
|
env["IRON_BOOTSTRAP_PASSWORD"] = "changeme-iron"
|
|
|
|
log_path = OUTPUT_DIR / "server.log"
|
|
log_handle = open(log_path, "w", encoding="utf-8")
|
|
|
|
return subprocess.Popen(
|
|
[
|
|
str(ROOT / ".venv" / "bin" / "python"),
|
|
"-m",
|
|
"uvicorn",
|
|
"app.main:app",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
],
|
|
cwd=ROOT,
|
|
env=env,
|
|
stdout=log_handle,
|
|
stderr=log_handle,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def wait_for_server(base_url: str) -> None:
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
try:
|
|
import urllib.request
|
|
|
|
with urllib.request.urlopen(f"{base_url}/api/system/health", timeout=1) as response:
|
|
if response.status == 200:
|
|
return
|
|
except Exception:
|
|
time.sleep(0.4)
|
|
|
|
log_path = OUTPUT_DIR / "server.log"
|
|
log_tail = log_path.read_text(encoding="utf-8") if log_path.exists() else ""
|
|
raise RuntimeError(f"Server did not start in time.\n{log_tail}")
|
|
|
|
|
|
def run_browser_flow(base_url: str) -> None:
|
|
with sync_playwright() as playwright:
|
|
browser = playwright.chromium.launch(
|
|
executable_path=CHROME_PATH,
|
|
headless=True,
|
|
)
|
|
context = browser.new_context(viewport={"width": 1440, "height": 1024})
|
|
page = context.new_page()
|
|
failures: list[str] = []
|
|
|
|
def track_response(response) -> None: # type: ignore[no-untyped-def]
|
|
if "/api/" in response.url and response.status >= 400:
|
|
failures.append(f"{response.status} {response.url}")
|
|
if ("/assets/" in response.url or "/icons/" in response.url) and response.status >= 400:
|
|
failures.append(f"static asset {response.status} {response.url}")
|
|
|
|
def track_request_failed(request) -> None: # type: ignore[no-untyped-def]
|
|
if "/api/" in request.url:
|
|
failure = request.failure or "unknown request failure"
|
|
failures.append(f"request failed {request.url}: {failure}")
|
|
|
|
page.on("response", track_response)
|
|
page.on("requestfailed", track_request_failed)
|
|
page.on("pageerror", lambda error: failures.append(f"page error: {error}"))
|
|
|
|
page.goto(f"{base_url}/app/login", wait_until="networkidle")
|
|
page.screenshot(path=str(OUTPUT_DIR / "login.png"), full_page=True)
|
|
|
|
page.get_by_label("Username").fill("admin")
|
|
page.get_by_label("Password").fill("changeme-iron")
|
|
page.get_by_role("button", name="Sign in").click()
|
|
page.wait_for_url(f"{base_url}/app/files")
|
|
page.locator(".files-header").filter(has_text="Personal").first.wait_for()
|
|
assert_sidebar_icons_render(page, failures)
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-initial.png"), full_page=True)
|
|
|
|
page.locator(".panel-toolbar").get_by_role("button", name="New").click()
|
|
page.get_by_role("dialog").get_by_label("Name").fill("playwright-folder")
|
|
page.get_by_role("dialog").get_by_role("button", name="Confirm").click()
|
|
page.get_by_role("dialog").wait_for(state="detached")
|
|
page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-after-create-folder.png"), full_page=True)
|
|
page.locator(".drive-item strong", has_text="playwright-folder").first.dblclick()
|
|
page.locator(".directory-title-row", has_text="playwright-folder").wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-folder-open.png"), full_page=True)
|
|
page.get_by_label("Back to parent folder").click()
|
|
page.locator(".files-header").filter(has_text="Personal").first.wait_for()
|
|
|
|
temp_upload = create_temp_upload_file()
|
|
preview_upload = create_temp_preview_file()
|
|
try:
|
|
page.locator(".files-command-bar").get_by_role("button", name="Upload", exact=True).click()
|
|
page.locator("input[type='file']").set_input_files(str(temp_upload))
|
|
page.locator(".drive-item strong", has_text="playwright-upload.txt").first.wait_for(timeout=15000)
|
|
page.locator(".files-command-bar").get_by_role("button", name="Upload", exact=True).click()
|
|
page.locator("input[type='file']").set_input_files(str(preview_upload))
|
|
page.locator(".drive-item strong", has_text="playwright-preview.png").first.wait_for(timeout=15000)
|
|
finally:
|
|
temp_upload.unlink(missing_ok=True)
|
|
preview_upload.unlink(missing_ok=True)
|
|
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-after-upload.png"), full_page=True)
|
|
|
|
page.locator(".drive-item strong", has_text="playwright-upload.txt").first.click()
|
|
page.locator(".detail-panel h3", has_text="playwright-upload.txt").wait_for()
|
|
page.locator(".drive-item", has_text="playwright-upload.txt").get_by_role("button", name="More").click()
|
|
with page.expect_download() as download_info:
|
|
page.get_by_role("menu").get_by_role("button", name="Download").click()
|
|
download = download_info.value
|
|
if download.suggested_filename != "playwright-upload.txt":
|
|
failures.append(f"unexpected download filename: {download.suggested_filename}")
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-detail-selected.png"), full_page=True)
|
|
|
|
page.locator(".drive-item strong", has_text="playwright-preview.png").first.click()
|
|
page.locator("img.preview-frame").wait_for(timeout=15000)
|
|
page.locator(".drive-item", has_text="playwright-preview.png").locator(".replica-ratio", has_text="1/1").wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-preview-selected.png"), full_page=True)
|
|
page.locator(".drive-item", has_text="playwright-preview.png").get_by_role("button", name="More").click()
|
|
page.get_by_role("menu").wait_for()
|
|
page.locator(".detail-panel").get_by_role("button", name="More Info").click()
|
|
if page.get_by_role("menu").count():
|
|
failures.append("file action menu should close when opening More Info drawer")
|
|
page.locator(".detail-drawer", has_text="Replica Info").wait_for()
|
|
page.locator(".detail-drawer", has_text="Desired Backends").wait_for()
|
|
page.locator(".detail-drawer", has_text="Replicas").wait_for()
|
|
page.locator(".detail-drawer", has_text="Placement").wait_for()
|
|
page.locator(".detail-drawer", has_text="Preview Artifacts").wait_for()
|
|
if page.locator(".detail-drawer").get_by_role("button", name="Reconcile File").count():
|
|
failures.append("detail drawer should be read-only; found Reconcile File action")
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-storage-selected.png"), full_page=True)
|
|
page.locator(".detail-drawer").get_by_role("button", name="Close").click()
|
|
page.locator(".files-command-bar").get_by_role("button", name="Grid").click()
|
|
page.locator(".drive-grid .drive-item", has_text="playwright-preview.png").get_by_role("button", name="More").click()
|
|
page.get_by_role("menu").get_by_role("button", name="Download").wait_for()
|
|
page.get_by_role("menu").get_by_role("button", name="Reconcile").wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-grid-menu.png"), full_page=True)
|
|
page.mouse.click(40, 120)
|
|
page.get_by_role("menu").wait_for(state="detached")
|
|
page.locator(".drive-grid .drive-item", has_text="playwright-preview.png").get_by_role("button", name="More").click()
|
|
page.get_by_role("menu").wait_for()
|
|
page.keyboard.press("Escape")
|
|
page.get_by_role("menu").wait_for(state="detached")
|
|
page.locator(".drive-grid .drive-item", has_text="playwright-preview.png").get_by_role("button", name="More").click()
|
|
page.get_by_role("menu").get_by_role("button", name="Delete").click()
|
|
delete_button = page.get_by_role("dialog").get_by_role("button", name="Confirm")
|
|
delete_button.wait_for()
|
|
delete_styles = delete_button.evaluate(
|
|
"(node) => { const style = getComputedStyle(node); return { color: style.color, backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage }; }"
|
|
)
|
|
has_visible_background = delete_styles["backgroundColor"] not in {
|
|
"rgba(0, 0, 0, 0)",
|
|
"transparent",
|
|
} or delete_styles["backgroundImage"] != "none"
|
|
if delete_styles["color"] != "rgb(255, 255, 255)" or not has_visible_background:
|
|
failures.append(f"delete confirm button has unreadable styles: {delete_styles}")
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-delete-dialog.png"), full_page=True)
|
|
page.get_by_role("dialog").get_by_role("button", name="Cancel").click()
|
|
page.get_by_role("dialog").wait_for(state="detached")
|
|
|
|
seed_file_pagination(page)
|
|
page.locator(".files-command-bar").get_by_role("button", name="Refresh").click()
|
|
page.locator(".drive-item strong", has_text="bulk-folder-00").wait_for(timeout=15000)
|
|
page.locator(".pagination-page-button", has_text="2").wait_for()
|
|
assert_pagination_controls(page, "Files", failures)
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-pagination.png"), full_page=True)
|
|
page.locator(".pagination-page-button", has_text="2").click()
|
|
page.locator(".drive-item strong", has_text="bulk-folder-08").wait_for()
|
|
page.locator(".page-size-select").select_option("16")
|
|
page.locator(".drive-item strong", has_text="playwright-folder").wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-pagination-page-size.png"), full_page=True)
|
|
|
|
page.get_by_label("Search").fill("playwright")
|
|
page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "files-search.png"), full_page=True)
|
|
|
|
page.goto(f"{base_url}/app/recycle-bin", wait_until="networkidle")
|
|
assert_pagination_controls(page, "Recycle bin", failures)
|
|
page.screenshot(path=str(OUTPUT_DIR / "recycle-bin-page.png"), full_page=True)
|
|
|
|
page.goto(f"{base_url}/app/storage", wait_until="networkidle")
|
|
assert_pagination_controls(page, "Storage", failures)
|
|
page.get_by_role("button", name="Run All Checks").click()
|
|
page.locator(".toast-item").first.wait_for()
|
|
page.get_by_role("button", name="Edit").first.click()
|
|
page.get_by_role("dialog").get_by_text("Stored secrets").wait_for()
|
|
page.get_by_role("dialog").get_by_label("Name").fill("local-default-renamed")
|
|
page.get_by_role("dialog").get_by_role("button", name="Save").click()
|
|
page.get_by_role("dialog").wait_for(state="detached")
|
|
page.locator(".drive-item strong", has_text="local-default-renamed").wait_for()
|
|
page.get_by_role("button", name="Edit").first.click()
|
|
page.get_by_role("dialog").get_by_text("Stored secrets").wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "storage-edit-dialog.png"), full_page=True)
|
|
page.mouse.click(24, 24)
|
|
page.get_by_role("dialog").wait_for(state="detached")
|
|
page.screenshot(path=str(OUTPUT_DIR / "storage-page.png"), full_page=True)
|
|
page.get_by_role("button", name="Add Backend").click()
|
|
page.get_by_role("dialog").get_by_label("Config JSON").wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "storage-create-dialog.png"), full_page=True)
|
|
page.mouse.click(24, 24)
|
|
page.get_by_role("dialog").wait_for(state="detached")
|
|
|
|
page.goto(f"{base_url}/app/policies", wait_until="networkidle")
|
|
page.get_by_role("button", name="Save Policy").wait_for()
|
|
page.get_by_role("button", name="Save Policy").click()
|
|
page.locator(".toast-item").first.wait_for()
|
|
page.screenshot(path=str(OUTPUT_DIR / "policies-page.png"), full_page=True)
|
|
|
|
page.goto(f"{base_url}/app/jobs", wait_until="networkidle")
|
|
assert_pagination_controls(page, "Jobs", failures)
|
|
page.locator(".jobs-table-row").first.click()
|
|
page.locator(".jobs-detail-panel", has_text="Job Detail").wait_for()
|
|
page.locator(".payload-preview").wait_for()
|
|
assert_jobs_id_layout(page, failures)
|
|
page.screenshot(path=str(OUTPUT_DIR / "jobs-page.png"), full_page=True)
|
|
|
|
mobile_context = browser.new_context(viewport={"width": 390, "height": 844})
|
|
mobile_page = mobile_context.new_page()
|
|
mobile_page.on("response", track_response)
|
|
mobile_page.on("requestfailed", track_request_failed)
|
|
mobile_page.on("pageerror", lambda error: failures.append(f"mobile page error: {error}"))
|
|
mobile_page.goto(f"{base_url}/app/login", wait_until="networkidle")
|
|
mobile_page.get_by_label("Username").fill("admin")
|
|
mobile_page.get_by_label("Password").fill("changeme-iron")
|
|
mobile_page.get_by_role("button", name="Sign in").click()
|
|
mobile_page.wait_for_url(f"{base_url}/app/files")
|
|
mobile_page.locator(".files-header").filter(has_text="Personal").first.wait_for()
|
|
mobile_page.screenshot(path=str(OUTPUT_DIR / "mobile-files.png"), full_page=True)
|
|
mobile_context.close()
|
|
|
|
context.close()
|
|
browser.close()
|
|
|
|
if failures:
|
|
raise AssertionError("Playwright E2E failures:\n" + "\n".join(failures))
|
|
|
|
|
|
def create_temp_upload_file() -> Path:
|
|
handle = tempfile.NamedTemporaryFile(delete=False, suffix="-playwright-upload.txt")
|
|
path = Path(handle.name)
|
|
handle.write(b"playwright upload validation\n")
|
|
handle.close()
|
|
target = path.with_name("playwright-upload.txt")
|
|
path.replace(target)
|
|
return target
|
|
|
|
|
|
def create_temp_preview_file() -> Path:
|
|
png_data = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yWZ0AAAAASUVORK5CYII="
|
|
)
|
|
handle = tempfile.NamedTemporaryFile(delete=False, suffix="-playwright-preview.png")
|
|
path = Path(handle.name)
|
|
handle.write(png_data)
|
|
handle.close()
|
|
target = path.with_name("playwright-preview.png")
|
|
path.replace(target)
|
|
return target
|
|
|
|
|
|
def assert_sidebar_icons_render(page, failures: list[str]) -> None: # type: ignore[no-untyped-def]
|
|
missing = page.locator(".main-nav .nav-symbol").evaluate_all(
|
|
"""(nodes) => nodes
|
|
.map((node) => getComputedStyle(node))
|
|
.filter((style) => style.webkitMaskImage === 'none' && style.maskImage === 'none')
|
|
.length"""
|
|
)
|
|
if missing:
|
|
failures.append(f"{missing} sidebar icon masks did not render")
|
|
|
|
|
|
def seed_file_pagination(page) -> None: # type: ignore[no-untyped-def]
|
|
page.evaluate(
|
|
"""async () => {
|
|
const token = window.localStorage.getItem("iron_access_token");
|
|
for (let index = 0; index < 10; index += 1) {
|
|
const name = `bulk-folder-${String(index).padStart(2, "0")}`;
|
|
const response = await fetch("/api/directories", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ parent_id: "dir_root", name }),
|
|
});
|
|
if (!response.ok && response.status !== 409) {
|
|
throw new Error(`Failed to seed ${name}: ${response.status}`);
|
|
}
|
|
}
|
|
}"""
|
|
)
|
|
|
|
|
|
def assert_pagination_controls(page, label: str, failures: list[str]) -> None: # type: ignore[no-untyped-def]
|
|
if not page.locator(".pagination-bar").count():
|
|
failures.append(f"{label} pagination bar missing")
|
|
return
|
|
if not page.locator(".pagination-page-button").count():
|
|
failures.append(f"{label} page buttons missing")
|
|
if not page.locator(".page-size-select").count():
|
|
failures.append(f"{label} page size selector missing")
|
|
|
|
|
|
def assert_jobs_id_layout(page, failures: list[str]) -> None: # type: ignore[no-untyped-def]
|
|
overlaps = page.locator(".jobs-detail-meta > div").evaluate_all(
|
|
"""(nodes) => nodes
|
|
.filter((node) => node.textContent && node.textContent.includes("ID"))
|
|
.map((node) => {
|
|
const label = node.querySelector("span");
|
|
const value = node.querySelector("strong");
|
|
if (!label || !value) return false;
|
|
const left = label.getBoundingClientRect();
|
|
const right = value.getBoundingClientRect();
|
|
return !(left.right <= right.left || right.right <= left.left || left.bottom <= right.top || right.bottom <= left.top);
|
|
})
|
|
.some(Boolean)"""
|
|
)
|
|
if overlaps:
|
|
failures.append("Jobs detail ID overlaps its label")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|