forked from retoor/devplacepy
Make dev workspaces serve a working browser IDE end to end
The workspace feature shipped its routes, agent tools and docs, but the editor was never reachable: the project page had no entry point, the ppy image had no code-server binary, no certificate was ever requested for a tunnel, and both nginx and the proxy dropped what the editor needs. - Add a VS Code button to the project action row and a Workspace item to the overflow menu, gated by can_open_workspace plus a running instance (viewer_can_workspace and workspace_editor_url on ProjectDetailOut). - Install a pinned code-server in ppy.Dockerfile before USER pravda and assert it in the build smoke test, so an image that cannot run the editor no longer builds green. - Run the editor with --auth password and a per workspace 8 character pronounceable secret, minted once at the ensure_editor_password choke point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the admin listing shares. - Publish the editor tunnel when a workspace is created and issue its certificate from a new WorkspaceService phase against the molohttp admin API, then notify the owner with the live URL and the password. Only pending rows are retried, so a broken host cannot burn the ACME failure rate limit. Renewal stays molohttp's job. - Forward the original Host on proxied requests and the client cookie on proxied websockets, so code-server scopes its session cookie to the public hostname and authenticates the workbench socket. - Recreate a container stuck in the created state instead of retrying docker start forever against an image it can no longer run. - Return a JSON string from WorkspaceController.dispatch; raw dicts landed in a tool message and aborted the turn at the model endpoint. - Let the nginx catch-all carry websocket upgrades, keeping upstream keepalive, so tunnelled apps and the editor both connect.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -46,6 +47,11 @@ def _project_for(owner_uid: str, title: str = "WS Project") -> dict:
|
||||
"title": title,
|
||||
"description": "workspace host project",
|
||||
"slug": slug,
|
||||
"project_type": "software",
|
||||
"status": "In Development",
|
||||
"platforms": "",
|
||||
"is_private": 0,
|
||||
"read_only": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"deleted_at": None,
|
||||
@@ -70,6 +76,91 @@ def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
|
||||
return store.create_instance(payload)
|
||||
|
||||
|
||||
def test_project_page_offers_the_workspace_entry_point_to_the_owner(alice):
|
||||
page, user = alice
|
||||
project = _project_for(_row_for(user)["uid"], "WS Entry")
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".project-actions-more").click()
|
||||
entry = page.locator(".context-menu-item:has-text('Workspace')")
|
||||
entry.wait_for(state="visible")
|
||||
entry.click()
|
||||
page.wait_for_url(
|
||||
f"{BASE_URL}/projects/{project['slug']}/workspace",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
page.locator(".workspace-page").wait_for(state="visible")
|
||||
|
||||
|
||||
def test_project_page_hides_the_workspace_entry_point_from_a_non_owner(bob):
|
||||
page, user = bob
|
||||
project = _project_for(str(uuid4()), "WS Entry Foreign")
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".project-actions-more").click()
|
||||
assert not page.locator(".context-menu-item:has-text('Workspace')").count()
|
||||
|
||||
|
||||
def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Direct")
|
||||
instance = _workspace_for(project, row["uid"])
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
button = page.locator(".project-detail-actions a:has-text('VS Code')")
|
||||
button.wait_for(state="visible")
|
||||
expect(button).to_have_attribute("target", "_blank")
|
||||
expect(button).to_have_attribute(
|
||||
"href",
|
||||
f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
|
||||
)
|
||||
|
||||
|
||||
def test_direct_vscode_button_is_absent_while_the_workspace_is_stopped(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Stopped")
|
||||
_workspace_for(project, row["uid"], status="stopped", desired_state="stopped")
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".project-detail-actions").wait_for(state="visible")
|
||||
assert not page.locator(".project-detail-actions a:has-text('VS Code')").count()
|
||||
|
||||
|
||||
def _await_workspace_flag(slug: str, key: str, expected: bool) -> bool:
|
||||
deadline = time.time() + 10.0
|
||||
url = f"{BASE_URL}/projects/{slug}"
|
||||
headers = {"Accept": "application/json", "X-API-KEY": key}
|
||||
while time.time() < deadline:
|
||||
body = requests.get(url, headers=headers).json()
|
||||
if body["viewer_can_workspace"] is expected:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def test_project_page_hides_the_workspace_entry_point_when_disabled(alice):
|
||||
page, user = alice
|
||||
row = _row_for(user)
|
||||
project = _project_for(row["uid"], "WS Entry Off")
|
||||
set_setting("workspace_enabled", "0")
|
||||
try:
|
||||
assert _await_workspace_flag(project["slug"], row["api_key"], False)
|
||||
page.goto(
|
||||
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".project-actions-more").click()
|
||||
assert not page.locator(".context-menu-item:has-text('Workspace')").count()
|
||||
finally:
|
||||
set_setting("workspace_enabled", "1")
|
||||
assert _await_workspace_flag(project["slug"], row["api_key"], True)
|
||||
|
||||
|
||||
def test_workspace_page_offers_creation_to_owner(alice):
|
||||
page, user = alice
|
||||
project = _project_for(_row_for(user)["uid"])
|
||||
|
||||
Reference in New Issue
Block a user