#!/usr/bin/env python3 """Run interactive, real-data Wiki Shortcut verification on a disposable space. Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real internal user ID that may be granted temporary access to the empty fixture. The script prints capability labels only; business IDs, names, URLs, and raw responses remain in memory. Commands that require confirmation use the normal `dws` terminal prompt, including the disposable-space cleanup in `finally`. """ from __future__ import annotations import json import os import subprocess import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DWS = ROOT / "dws" MEMBER_ID = os.environ.get("DWS_WIKI_E2E_MEMBER_ID", "").strip() class E2EFailure(RuntimeError): pass def invoke(args: list[str], *, require_confirmation: bool = False) -> dict: process = subprocess.run( [str(DWS), "wiki", *args, "--format", "json"], cwd=ROOT, stdout=subprocess.PIPE, # Confirmation prompts are written to stderr. Keep that stream on the # terminal for guarded operations so dws itself obtains the user's # answer; ordinary calls stay quiet and redact raw backend errors. stderr=None if require_confirmation else subprocess.PIPE, text=True, check=False, ) try: envelope = json.loads(process.stdout) except json.JSONDecodeError as exc: raise E2EFailure(f"non-JSON response (exit {process.returncode})") from exc if process.returncode != 0 or envelope.get("ok") is not True: error = envelope.get("error") or {} reason = error.get("reason") or error.get("category") or "command_failed" raise E2EFailure(f"{reason} (exit {process.returncode})") data = envelope.get("data") if not isinstance(data, dict): raise E2EFailure("success envelope lacks object data") return data def check(label: str, condition: bool) -> None: if not condition: raise E2EFailure(f"{label}: business assertion failed") print(f"PASS {label}") def member_role(data: dict, user_id: str) -> str: for member in data.get("members", []): if member.get("id") == user_id: return str(member.get("role") or "").upper() return "" def main() -> int: if not sys.stdin.isatty() or not sys.stderr.isatty(): raise E2EFailure( "run in an interactive terminal; guarded operations require an explicit dws confirmation" ) if not DWS.exists(): raise E2EFailure("build ./dws first with make build") if not MEMBER_ID: raise E2EFailure("set DWS_WIKI_E2E_MEMBER_ID to a temporary internal test member") stamp = time.strftime("%m%d%H%M%S") space_name = f"DWS Wiki E2E {stamp}" # <= 32 characters workspace = "" disposable_nodes: list[str] = [] member_added = False try: created = invoke(["+space-create", "--name", space_name, "--desc", "Disposable E2E fixture"]) workspace = str(created.get("workspaceId") or "") check("space-create-readback", bool(workspace) and created.get("space", {}).get("workspaceId") == workspace) page = invoke(["+space-list", "--limit", "1"]) check("space-list-cursor", page.get("count") == 1 and isinstance(page.get("hasMore"), bool)) all_spaces = invoke(["+space-list", "--limit", "1", "--page-all", "--max-items", "2"]) check("space-list-auto-page", all_spaces.get("count") == 2 and len(all_spaces.get("spaces", [])) == 2) search_ok = False for _ in range(8): searched = invoke(["+space-search", "--query", space_name]) if any(row.get("workspaceId") == workspace for row in searched.get("spaces", [])): search_ok = True break time.sleep(2) check("space-search", search_ok) detail = invoke(["+space-get", "--workspace", workspace]) check("space-get", detail.get("workspaceId") == workspace) resolved = invoke(["+resolve-space", "--name", space_name]) check("resolve-space", resolved.get("resolved") is True and resolved.get("spaceId") == workspace) empty_nodes = invoke(["+node-list", "--workspace", workspace]) check("node-list-explicit-empty", empty_nodes.get("count") == 0 and empty_nodes.get("nodes") == []) folder = invoke(["+node-create", "--workspace", workspace, "--name", "E2E Folder", "--type", "folder"]) folder_id = str(folder.get("nodeId") or "") disposable_nodes.append(folder_id) check("node-create-folder-readback", bool(folder_id) and folder.get("node", {}).get("nodeId") == folder_id) document = invoke(["+node-create", "--workspace", workspace, "--folder", folder_id, "--name", "E2E Document"]) node_id = str(document.get("nodeId") or "") disposable_nodes.append(node_id) check("node-create-document-readback", bool(node_id) and document.get("node", {}).get("nodeId") == node_id) listed = invoke(["+node-list", "--workspace", workspace, "--limit", "1"]) check("node-list-cursor", listed.get("count") == 1 and listed.get("hasMore") is True and bool(listed.get("nextCursor"))) all_nodes = invoke(["+node-list", "--workspace", workspace, "--limit", "1", "--page-all"]) check( "node-list-auto-page", all_nodes.get("count", 0) >= 1 and (all_nodes.get("autoPageComplete") is True or all_nodes.get("hasMore") is False), ) info = invoke(["+node-get", "--node", node_id]) check("node-get", info.get("nodeId") == node_id) # Search indexing can lag after a create; retry without accepting a # malformed/missing collection as an empty success. search_ok = False for _ in range(6): found = invoke(["+node-search", "--workspace", workspace, "--query", "E2E Document"]) if any(row.get("nodeId") == node_id for row in found.get("nodes", [])): search_ok = True break time.sleep(2) check("node-search", search_ok) copied = invoke( ["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id], require_confirmation=True, ) copy_id = str(copied.get("nodeId") or "") disposable_nodes.append(copy_id) check("node-copy-readback", bool(copy_id) and bool(copied.get("copy"))) moved = invoke( ["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id], require_confirmation=True, ) check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id) moved_out = invoke(["+move-to-drive", "--node", node_id], require_confirmation=True) check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace) moved_back = invoke( ["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id], require_confirmation=True, ) check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace) named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"]) named_id = str(named.get("nodeId") or "") disposable_nodes.append(named_id) check("wiki-new-doc-readback", bool(named_id) and named.get("document", {}).get("nodeId") == named_id) members = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list)) added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"]) member_added = True check("member-add-terminal", added.get("success") is True and added.get("verifiedBy") == "write_terminal_success") after_add = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) check("member-add-fixture-readback", after_add.get("truncated") is not True and member_role(after_add, MEMBER_ID) == "READER") updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"]) check("member-update-terminal", updated.get("success") is True and updated.get("verifiedBy") == "write_terminal_success") after_update = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) check("member-update-fixture-readback", after_update.get("truncated") is not True and member_role(after_update, MEMBER_ID) == "EDITOR") removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID]) member_added = False check("member-remove-terminal", removed.get("success") is True and removed.get("verifiedBy") == "write_terminal_success") after_remove = invoke(["+member-list", "--workspace", workspace, "--limit", "50"]) check("member-remove-fixture-readback", after_remove.get("truncated") is not True and member_role(after_remove, MEMBER_ID) == "") feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"]) check("feed-list", isinstance(feeds.get("feeds"), list)) # Exercise high-risk delete before final whole-space cleanup. delete_target = disposable_nodes.pop() deleted = invoke( ["+node-delete", "--workspace", workspace, "--node", delete_target], require_confirmation=True, ) check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True) return 0 finally: if workspace: if member_added: try: invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID]) except Exception: pass try: deleted = invoke( ["+space-delete", "--workspace", workspace], require_confirmation=True, ) check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True) except Exception as exc: print(f"CLEANUP FAILED: {exc}", file=sys.stderr) if __name__ == "__main__": try: raise SystemExit(main()) except E2EFailure as exc: print(f"FAIL {exc}", file=sys.stderr) raise SystemExit(1)