Files
wehub-resource-sync e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

338 lines
13 KiB
Python

#!/usr/bin/env python3
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Simple smoke tests for execd APIs.
Prerequisites:
- execd server running locally (default http://localhost:44772)
- Optional: set env BASE_URL to override
- Optional: set env API_TOKEN if server expects X-EXECD-ACCESS-TOKEN
"""
import json
import os
import sys
import time
import uuid
import tempfile
import pathlib
import requests
BASE_URL = os.environ.get("BASE_URL", "http://localhost:44772").rstrip("/")
API_TOKEN = os.environ.get("API_TOKEN")
HEADERS = {}
if API_TOKEN:
HEADERS["X-EXECD-ACCESS-TOKEN"] = API_TOKEN
session = requests.Session()
session.headers.update(HEADERS)
def expect(cond: bool, msg: str):
if not cond:
raise SystemExit(msg)
def sse_get_command_id() -> str:
url = f"{BASE_URL}/command"
payload = {"command": "echo smoke-command && sleep 1", "background": True}
with session.post(url, json=payload, stream=True, timeout=15) as resp:
expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}")
for line in resp.iter_lines():
if not line or not line.startswith(b"data:"):
# controller emits raw JSON lines without SSE 'data:' prefix
try:
data = json.loads(line.decode())
except Exception:
continue
else:
data = json.loads(line[len(b"data:") :].decode())
if data.get("type") == "init":
cmd_id = data.get("text")
expect(cmd_id, "missing command id in init event")
return cmd_id
raise SystemExit("Failed to obtain command id from SSE")
def wait_status(cmd_id: str, timeout: float = 15.0) -> dict:
url = f"{BASE_URL}/command/status/{cmd_id}"
deadline = time.time() + timeout
last = None
while time.time() < deadline:
r = session.get(url, timeout=5)
expect(r.status_code == 200, f"status failed: {r.status_code} {r.text}")
last = r.json()
if not last.get("running", True):
return last
time.sleep(0.3)
return last
def fetch_logs(cmd_id: str, cursor: int = 0):
url = f"{BASE_URL}/command/{cmd_id}/logs"
r = session.get(url, params={"cursor": cursor}, timeout=10)
expect(r.status_code == 200, f"logs failed: {r.status_code} {r.text}")
return r.text, r.headers.get("EXECD-COMMANDS-TAIL-CURSOR")
def run_command_blank_lines():
"""
Foreground command whose stdout contains consecutive newlines must surface
blank-line events instead of dropping them. Regression test for the
readFromPos fix that preserves empty lines (a\n\nb -> ["a", "\n", "b"]).
"""
url = f"{BASE_URL}/command"
# Pick a shell-native command per platform so the regression covers both
# POSIX (LF-only) and Windows cmd (CRLF) byte streams without depending on
# Git for Windows / MSYS argv mangling. The execd reader collapses CRLF to
# LF, so both produce ["a", "\n", "b", "\n", "\n", "c"].
if os.name == "nt":
# cmd /C echo chain: each segment writes "<text>\r\n"; "echo." writes
# a bare "\r\n". Order is deterministic because "&" is sequential.
command = "echo a&echo.&echo b&echo.&echo.&echo c"
else:
# printf emits exact bytes: a\n\nb\n\n\nc\n
command = "printf 'a\\n\\nb\\n\\n\\nc\\n'"
payload = {
"command": command,
"background": False,
}
stdout_texts = []
saw_complete = False
with session.post(url, json=payload, stream=True, timeout=15) as resp:
expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}")
for line in resp.iter_lines():
if not line:
continue
try:
if line.startswith(b"data:"):
data = json.loads(line[len(b"data:") :].decode())
else:
data = json.loads(line.decode())
except Exception:
continue
event_type = data.get("type")
if event_type == "stdout":
stdout_texts.append(data.get("text", ""))
elif event_type == "execution_complete":
saw_complete = True
break
expect(saw_complete, "did not observe execution_complete")
want = ["a", "\n", "b", "\n", "\n", "c"]
expect(
stdout_texts == want,
f"blank-line stdout sequence mismatch: got {stdout_texts!r}, want {want!r}",
)
def sse_disconnect_should_stop_ping():
"""
Open an SSE stream for a long-running command, receive init, then close the
client side early to ensure the server handles disconnects (ping loop should
stop). We verify the server is still responsive afterwards.
"""
url = f"{BASE_URL}/command"
payload = {
# long command so the server would keep pinging if not cancelled
"command": "sh -c 'echo long-run-start && sleep 20 && echo long-run-end'",
"background": False,
}
with session.post(url, json=payload, stream=True, timeout=10) as resp:
expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}")
for line in resp.iter_lines():
if not line:
continue
try:
if line.startswith(b"data:"):
data = json.loads(line[len(b"data:") :].decode())
else:
data = json.loads(line.decode())
except Exception:
continue
if data.get("type") == "init":
break
# explicitly close to simulate client drop
resp.close()
# Give server a moment to observe disconnect and ensure API remains healthy
time.sleep(1)
pong = session.get(f"{BASE_URL}/ping", timeout=5)
expect(pong.status_code == 200, "ping failed after SSE disconnect")
def upload_and_download():
tmp_dir = f"/tmp/execd-smoke-{uuid.uuid4().hex}"
path = f"{tmp_dir}/hello.txt"
metadata = json.dumps({"path": path})
files = {
"metadata": ("metadata", metadata, "application/json"),
"file": ("file", b"hello execd\n", "application/octet-stream"),
}
up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10)
expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}")
down = session.get(f"{BASE_URL}/files/download", params={"path": path}, timeout=10)
expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}")
expect(down.content == b"hello execd\n", "downloaded content mismatch")
def filesystem_smoke():
base_dir = os.path.join(tempfile.gettempdir(), f"execd-smoke-{uuid.uuid4().hex}")
sub_dir = os.path.join(base_dir, "sub")
file_path = os.path.join(sub_dir, "hello.txt")
renamed_path = os.path.join(sub_dir, "hello_renamed.txt")
home_dir = os.path.expanduser("~")
home_file_name = f"execd-smoke-home-{uuid.uuid4().hex}.txt"
home_file_abs = os.path.join(home_dir, home_file_name)
# Windows uses backslash path style by default; keep smoke path style aligned
# with platform so "~" expansion is exercised in a realistic way.
home_file_tilde = f"~\\{home_file_name}" if os.name == "nt" else f"~/{home_file_name}"
# create dirs
mk = session.post(f"{BASE_URL}/directories", json={sub_dir: {"mode": 0}}, timeout=10)
expect(mk.status_code == 200, f"mkdir failed: {mk.status_code} {mk.text}")
# upload a file
metadata = json.dumps({"path": file_path})
files = {
"metadata": ("metadata", metadata, "application/json"),
"file": ("file", b"hello execd\n", "application/octet-stream"),
}
up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10)
expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}")
# get info
info = session.get(f"{BASE_URL}/files/info", params={"path": [file_path]}, timeout=10)
expect(info.status_code == 200, f"info failed: {info.status_code} {info.text}")
# list directory contents
listed = session.get(f"{BASE_URL}/directories/list", params={"path": sub_dir}, timeout=10)
expect(listed.status_code == 200, f"list directory failed: {listed.status_code} {listed.text}")
listed_file = None
for entry in listed.json():
p = entry.get("path")
if p and pathlib.Path(p).resolve() == pathlib.Path(file_path).resolve():
listed_file = entry
break
expect(listed_file is not None, "directory list did not find file")
expect(listed_file.get("type") == "file", f"directory list file type mismatch: {listed_file}")
# search
search = session.get(f"{BASE_URL}/files/search", params={"path": base_dir, "pattern": "*.txt"}, timeout=10)
expect(search.status_code == 200, f"search failed: {search.status_code} {search.text}")
found = False
for f in search.json():
p = f.get("path")
if not p:
continue
if pathlib.Path(p).resolve() == pathlib.Path(file_path).resolve():
found = True
break
expect(found, "search did not find file")
# replace content
rep = session.post(
f"{BASE_URL}/files/replace",
json={file_path: {"old": "hello", "new": "hi"}},
timeout=10,
)
expect(rep.status_code == 200, f"replace failed: {rep.status_code} {rep.text}")
# download to verify replace
down = session.get(f"{BASE_URL}/files/download", params={"path": file_path}, timeout=10)
expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}")
expect(down.content == b"hi execd\n", "replace content mismatch")
# chmod (mode only)
chmod = session.post(f"{BASE_URL}/files/permissions", json={file_path: {"mode": 644}}, timeout=10)
expect(chmod.status_code == 200, f"chmod failed: {chmod.status_code} {chmod.text}")
# rename
mv = session.post(
f"{BASE_URL}/files/mv",
json=[{"src": file_path, "dest": renamed_path}],
timeout=10,
)
expect(mv.status_code == 200, f"rename failed: {mv.status_code} {mv.text}")
# remove file
rm_file = session.delete(f"{BASE_URL}/files", params={"path": [renamed_path]}, timeout=10)
expect(rm_file.status_code == 200, f"remove file failed: {rm_file.status_code} {rm_file.text}")
# read file using "~/<file>" style path
home_metadata = json.dumps({"path": home_file_abs})
home_files = {
"metadata": ("metadata", home_metadata, "application/json"),
"file": ("file", b"home path content\n", "application/octet-stream"),
}
home_up = session.post(f"{BASE_URL}/files/upload", files=home_files, timeout=10)
expect(home_up.status_code == 200, f"home upload failed: {home_up.status_code} {home_up.text}")
home_down = session.get(f"{BASE_URL}/files/download", params={"path": home_file_tilde}, timeout=10)
# On Windows, also accept "~/" form as a compatibility fallback.
if home_down.status_code != 200 and os.name == "nt":
alt_tilde = f"~/{home_file_name}"
home_down = session.get(f"{BASE_URL}/files/download", params={"path": alt_tilde}, timeout=10)
expect(home_down.status_code == 200, f"home download via tilde failed: {home_down.status_code} {home_down.text}")
expect(home_down.content == b"home path content\n", "home download content mismatch")
home_rm = session.delete(f"{BASE_URL}/files", params={"path": [home_file_tilde]}, timeout=10)
expect(home_rm.status_code == 200, f"home remove failed: {home_rm.status_code} {home_rm.text}")
# remove dir
rm_dir = session.delete(f"{BASE_URL}/directories", params={"path": [base_dir]}, timeout=10)
expect(rm_dir.status_code == 200, f"remove dir failed: {rm_dir.status_code} {rm_dir.text}")
def main():
print(f"[+] base: {BASE_URL}")
r = session.get(f"{BASE_URL}/ping", timeout=5)
expect(r.status_code == 200, "ping failed")
print("[+] ping ok")
sse_disconnect_should_stop_ping()
print("[+] SSE disconnect handled")
run_command_blank_lines()
print("[+] run_command preserves blank lines")
cmd_id = sse_get_command_id()
print(f"[+] command id: {cmd_id}")
status = wait_status(cmd_id)
print(f"[+] status: {status}")
logs, cursor = fetch_logs(cmd_id, cursor=0)
print(f"[+] logs (cursor={cursor}):\n{logs}")
filesystem_smoke()
print("[+] filesystem APIs ok")
print("[+] smoke tests PASS")
if __name__ == "__main__":
try:
main()
except SystemExit as exc:
print(f"[!] smoke tests FAIL: {exc}", file=sys.stderr)
sys.exit(1)