提交完善
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s

This commit is contained in:
2026-05-28 18:20:49 +08:00
parent ea25fc2638
commit abe02f26cf
4 changed files with 363 additions and 4 deletions

View File

@@ -54,6 +54,30 @@ finally:
client.disconnect()
```
### 桌面 E2E`jiangchang_desktop_sdk.e2e_helpers`
Skill 的桌面端到端测试应通过宿主 IPC 获取**真实**当前用户数据目录,不要在 pytest 进程里手工设置 `JIANGCHANG_USER_ID` / `CLAW_USER_ID` 来伪造用户(否则会回退到 `_anon`)。
推荐 preflight 流程:
1. `require_logged_in_skill_data_dir(page, skill_slug)` — 经 `jiangchang:resolve-skill-data-dir` 解析路径,并断言已登录(非 `_anon` / `anon` / `anonymous`)。
2. `assert_skill_env_file(ctx, ["API_KEY", ...])` — 检查 `{skill_data}/config/.env` 中必填项已配置且非占位符E2E 不会自动生成真实密钥。
3. `send_prompt_via_composer(page, prompt)` — 普通文本逐字键入;内容中的 `\n` / `\r\n` / `\r`**Shift+Enter** 输入换行;最后用 **Enter** 发送(禁止 DOM 注入、剪贴板、JS 伪造 input/change
诊断时可调用 `get_runtime_env(page)` 查看主进程解析的环境,**不要**用它拼用户数据路径。
```python
from jiangchang_desktop_sdk.e2e_helpers import (
assert_skill_env_file,
require_logged_in_skill_data_dir,
send_prompt_via_composer,
)
ctx = require_logged_in_skill_data_dir(page, "demo-skill")
assert_skill_env_file(ctx, ["API_KEY"])
send_prompt_via_composer(page, "第一行\n第二行")
```
## 5. 发版流程(维护者)
### main 分支自动预发布

View File

@@ -25,13 +25,19 @@ from __future__ import annotations
import base64
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
from typing import Iterator, Optional, Tuple
from playwright.sync_api import Page
__all__ = [
"SkillDataDir",
"resolve_skill_data_dir",
"require_logged_in_skill_data_dir",
"get_runtime_env",
"assert_skill_env_file",
"drop_file_into_composer",
"wait_for_attachment_ready",
"wait_for_composer_enabled",
@@ -40,11 +46,209 @@ __all__ = [
"wait_for_agent_complete",
]
_NEWLINE = object()
_ANONYMOUS_USER_IDS = frozenset({"", "_anon", "anon", "anonymous"})
_DEFAULT_PLACEHOLDER_VALUES = frozenset({
"",
"xxx",
"sk-xxx",
"your-key",
"your_api_key",
"todo",
"changeme",
"replace-me",
"<your-key>",
})
@dataclass(frozen=True)
class SkillDataDir:
"""匠厂宿主解析出的 skill 用户数据目录上下文。"""
root: Path
path: Path
user_id: str
skill_slug: str
def _normalize_ws(text: str) -> str:
return re.sub(r"\s+", "", text)
def _invoke_ipc(page: Page, channel: str, payload: dict | None = None) -> dict:
"""通过匠厂 preload 暴露的 IPC 调用主进程 handler。"""
result = page.evaluate(
"""
async ({ channel, payload }) => {
const electron = window.electron;
if (!electron || !electron.ipcRenderer
|| typeof electron.ipcRenderer.invoke !== 'function') {
throw new Error(
'window.electron.ipcRenderer.invoke 不可用:请在匠厂桌面客户端页面内运行 E2E'
+ '且 preload 已暴露 jiangchang IPC'
);
}
try {
if (payload === null || payload === undefined) {
return await electron.ipcRenderer.invoke(channel);
}
return await electron.ipcRenderer.invoke(channel, payload);
} catch (err) {
const msg = err && err.message ? err.message : String(err);
throw new Error(channel + ' IPC 调用失败: ' + msg);
}
}
""",
{"channel": channel, "payload": payload},
)
if not isinstance(result, dict):
raise RuntimeError(
f"{channel} 返回非对象: {result!r}"
)
return result
def resolve_skill_data_dir(
page: Page,
skill_slug: str,
*,
create: bool = True,
) -> SkillDataDir:
"""通过宿主 IPC 解析当前登录用户下的 skill 数据目录(不拼路径、不用 _anon 默认值)。"""
raw = _invoke_ipc(
page,
"jiangchang:resolve-skill-data-dir",
{"skillSlug": skill_slug},
)
missing = [k for k in ("root", "path", "userId", "skillSlug") if not raw.get(k)]
if missing:
raise RuntimeError(
"jiangchang:resolve-skill-data-dir 返回结构不完整,"
f"缺少字段 {missing},实际: {raw!r}"
)
data_path = Path(str(raw["path"]))
if create:
data_path.mkdir(parents=True, exist_ok=True)
return SkillDataDir(
root=Path(str(raw["root"])),
path=data_path,
user_id=str(raw["userId"]),
skill_slug=str(raw["skillSlug"]),
)
def require_logged_in_skill_data_dir(page: Page, skill_slug: str) -> SkillDataDir:
"""解析 skill 数据目录,并断言当前宿主已同步真实登录用户(非匿名)。"""
ctx = resolve_skill_data_dir(page, skill_slug)
uid = (ctx.user_id or "").strip().lower()
if uid in _ANONYMOUS_USER_IDS:
raise AssertionError(
"当前宿主没有同步登录用户 ID收到 "
f"{ctx.user_id!r}),不能运行桌面 E2E。"
"请先在匠厂客户端登录,或检查 jiangchangUserId 是否已同步到设置。"
)
return ctx
def get_runtime_env(page: Page) -> dict:
"""诊断用:读取宿主主进程解析的运行时环境(勿用于拼路径)。"""
return _invoke_ipc(page, "jiangchang:get-runtime-env", None)
def _strip_env_value(raw: str) -> str:
value = raw.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
return value[1:-1]
return value
def _parse_env_file(env_path: Path) -> dict[str, str]:
"""解析简单 KEY=value .env无 shell 展开)。"""
parsed: dict[str, str] = {}
text = env_path.read_text(encoding="utf-8")
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, raw_value = stripped.partition("=")
key = key.strip()
if not key:
continue
parsed[key] = _strip_env_value(raw_value)
return parsed
def _is_placeholder_value(value: str, extra: set[str] | None) -> bool:
normalized = value.strip().lower()
placeholders = _DEFAULT_PLACEHOLDER_VALUES
if extra:
placeholders = placeholders | {p.strip().lower() for p in extra}
return normalized in placeholders
def assert_skill_env_file(
skill_data_dir: SkillDataDir,
required_keys: list[str] | tuple[str, ...],
*,
env_rel_path: str = "config/.env",
placeholder_values: set[str] | None = None,
) -> Path:
"""Preflight检查 skill 用户数据目录下 config/.env 是否已配置所需密钥。"""
env_path = skill_data_dir.path / env_rel_path
if not env_path.is_file():
raise AssertionError(
f"缺少配置文件: {env_path}\n"
"E2E 测试不会自动生成真实密钥,请先一次性配置用户数据目录 config/.env"
)
env_map = _parse_env_file(env_path)
invalid: list[str] = []
for key in required_keys:
if key not in env_map:
invalid.append(key)
continue
value = env_map[key]
if not value.strip() or _is_placeholder_value(value, placeholder_values):
invalid.append(key)
if invalid:
raise AssertionError(
f"config/.env 配置不完整或仍为占位符: {env_path}\n"
f"缺失/无效的 key: {', '.join(invalid)}\n"
"E2E 测试不会自动生成真实密钥,请先一次性配置用户数据目录 config/.env"
)
return env_path
def _iter_text_and_newlines(prompt: str) -> Iterator[str | object]:
"""按真实输入顺序产出文本片段与换行标记(\\r\\n / \\n / \\r 均视为单次换行)。"""
i = 0
n = len(prompt)
text_start = 0
while i < n:
if i + 1 < n and prompt[i] == "\r" and prompt[i + 1] == "\n":
if i > text_start:
yield prompt[text_start:i]
yield _NEWLINE
i += 2
text_start = i
elif prompt[i] in "\n\r":
if i > text_start:
yield prompt[text_start:i]
yield _NEWLINE
i += 1
text_start = i
else:
i += 1
if text_start < n:
yield prompt[text_start:]
# ─── 附件上传(绕开 SDK send_file 未实现) ──────────────────────────
def drop_file_into_composer(page: Page, file_path: Path) -> None:
@@ -138,11 +342,16 @@ def wait_for_composer_enabled(page: Page, timeout_s: float = 15.0) -> None:
def send_prompt_via_composer(page: Page, prompt: str, *, delay_ms: int = 25) -> None:
"""拟人输入 + Enter 发送。不做任何等待,返回即调用结束"""
"""拟人逐字输入 + Shift+Enter 换行 + Enter 发送(禁止 DOM/剪贴板/伪造事件)"""
textarea = page.locator('[data-testid="chat-composer-input"]')
textarea.click()
textarea.fill("")
textarea.press_sequentially(prompt, delay=delay_ms)
textarea.press("Control+A")
textarea.press("Backspace")
for segment in _iter_text_and_newlines(prompt):
if segment is _NEWLINE:
textarea.press("Shift+Enter")
else:
textarea.press_sequentially(str(segment), delay=delay_ms)
textarea.press("Enter")

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Package marker for unittest discovery.

125
tests/test_e2e_helpers.py Normal file
View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""e2e_helpers 轻量单元测试(不依赖匠厂桌面 / IPC"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from jiangchang_desktop_sdk.e2e_helpers import (
SkillDataDir,
_iter_text_and_newlines,
_is_placeholder_value,
_parse_env_file,
assert_skill_env_file,
)
def _segments(prompt: str) -> list[str | None]:
out: list[str | None] = []
for seg in _iter_text_and_newlines(prompt):
if seg is not None and not isinstance(seg, str):
out.append(None)
else:
out.append(seg) # type: ignore[arg-type]
return out
class TestIterTextAndNewlines(unittest.TestCase):
def test_lf(self) -> None:
self.assertEqual(_segments("a\nb"), ["a", None, "b"])
def test_crlf(self) -> None:
self.assertEqual(_segments("a\r\nb"), ["a", None, "b"])
def test_cr(self) -> None:
self.assertEqual(_segments("a\rb"), ["a", None, "b"])
def test_multiple_newlines(self) -> None:
self.assertEqual(_segments("x\n\ny"), ["x", None, None, "y"])
class TestParseEnvFile(unittest.TestCase):
def _write(self, path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def test_basic_and_comments(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / ".env"
self._write(
env_path,
"# comment\n\nAPI_KEY=real-secret\n# KEY=ignored\n",
)
parsed = _parse_env_file(env_path)
self.assertEqual(parsed["API_KEY"], "real-secret")
self.assertNotIn("KEY", parsed)
def test_quoted_values(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env_path = Path(tmp) / ".env"
self._write(env_path, 'A="double"\nB=\'single\'\n')
parsed = _parse_env_file(env_path)
self.assertEqual(parsed["A"], "double")
self.assertEqual(parsed["B"], "single")
def test_placeholder_detection(self) -> None:
self.assertTrue(_is_placeholder_value("TODO", None))
self.assertTrue(_is_placeholder_value("sk-xxx", None))
self.assertTrue(_is_placeholder_value(" Changeme ", None))
self.assertFalse(_is_placeholder_value("sk-live-abc", None))
class TestAssertSkillEnvFile(unittest.TestCase):
def test_missing_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
ctx = SkillDataDir(
root=Path(tmp),
path=Path(tmp) / "user" / "demo-skill",
user_id="12428",
skill_slug="demo-skill",
)
with self.assertRaises(AssertionError) as cm:
assert_skill_env_file(ctx, ["API_KEY"])
self.assertIn("config/.env", str(cm.exception))
def test_missing_and_placeholder_keys(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill_dir = Path(tmp) / "12428" / "demo-skill"
env_dir = skill_dir / "config"
env_dir.mkdir(parents=True)
(env_dir / ".env").write_text(
"API_KEY=your-key\nOTHER=real\n",
encoding="utf-8",
)
ctx = SkillDataDir(
root=Path(tmp),
path=skill_dir,
user_id="12428",
skill_slug="demo-skill",
)
with self.assertRaises(AssertionError) as cm:
assert_skill_env_file(ctx, ["API_KEY", "MISSING", "OTHER"])
msg = str(cm.exception)
self.assertIn("API_KEY", msg)
self.assertIn("MISSING", msg)
self.assertNotIn("OTHER", msg)
def test_pass_returns_path(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill_dir = Path(tmp) / "1" / "demo-skill"
env_dir = skill_dir / "config"
env_dir.mkdir(parents=True)
env_file = env_dir / ".env"
env_file.write_text("API_KEY=sk-live-ok\n", encoding="utf-8")
ctx = SkillDataDir(
root=Path(tmp),
path=skill_dir,
user_id="1",
skill_slug="demo-skill",
)
got = assert_skill_env_file(ctx, ["API_KEY"])
self.assertEqual(got, env_file)
if __name__ == "__main__":
unittest.main()