dsh-origin-bridge 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +299 -234
- package/asciiio.py +82 -12
- package/docs/preview.png +0 -0
- package/edge_checks.py +10 -0
- package/originlab.py +475 -50
- package/package.json +76 -74
- package/scripts/make_preview.py +77 -0
- package/server.py +612 -577
- package/smoke_test.py +37 -7
- package/test_lifecycle.py +232 -0
package/smoke_test.py
CHANGED
|
@@ -7,18 +7,24 @@ Exits non-zero on the first hard failure. Writes artifacts to ./out/.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
import math
|
|
10
11
|
import os
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
14
15
|
import time
|
|
15
16
|
|
|
17
|
+
import originlab # only for the instance-table cleanup at the end; no COM until called
|
|
18
|
+
|
|
16
19
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
17
20
|
OUT = os.path.join(HERE, "out")
|
|
18
21
|
# Point this at the installed copy (…/node_modules/dsh-origin-bridge/server.py)
|
|
19
22
|
# to regression-test what dsh actually runs, without littering that folder.
|
|
20
23
|
SERVER = os.environ.get("ORIGIN_BRIDGE_SERVER") or os.path.join(HERE, "server.py")
|
|
21
24
|
PY = sys.executable
|
|
25
|
+
# ISO-8601 local timestamp of this run, used to tell our own leftover Origin
|
|
26
|
+
# instances from a session that was already open.
|
|
27
|
+
STARTED_AT = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
22
28
|
|
|
23
29
|
|
|
24
30
|
class Rpc:
|
|
@@ -112,11 +118,16 @@ def main():
|
|
|
112
118
|
|
|
113
119
|
tools = rpc.request("tools/list").get("result", {}).get("tools", [])
|
|
114
120
|
names = sorted(t["name"] for t in tools)
|
|
115
|
-
check("tools/list returns
|
|
121
|
+
check("tools/list returns 27", len(tools) == 27, len(tools))
|
|
122
|
+
check("实例治理工具已暴露", {"origin_instances", "origin_reclaim"} <= set(names),
|
|
123
|
+
[n for n in names if "instance" in n or "reclaim" in n])
|
|
124
|
+
# Tools that genuinely take no arguments are the only ones allowed an empty
|
|
125
|
+
# properties block; everything else must describe its inputs.
|
|
126
|
+
no_arg = {"origin_status", "origin_instances"}
|
|
116
127
|
for t in tools:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
props = t.get("inputSchema", {}).get("properties") or {}
|
|
129
|
+
check("schema present: %s" % t["name"], bool(props) or t["name"] in no_arg, len(props))
|
|
130
|
+
check("无参工具确实无参: %s" % t["name"], t["name"] not in no_arg or not props, sorted(props))
|
|
120
131
|
print(" %s" % ", ".join(names))
|
|
121
132
|
|
|
122
133
|
# origin_read_file is pure Python, so it is checked in both modes.
|
|
@@ -299,15 +310,22 @@ def main():
|
|
|
299
310
|
"data": os.path.join(HERE, "sample.dat")})
|
|
300
311
|
check("chart_check reads columns from the file", pv.get("ok") and
|
|
301
312
|
pv.get("columns_detected") == ["X", "Y"], (pv.get("columns_detected"), pv.get("issues")))
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
313
|
+
xrd = {"two_theta": [round(20 + i * 1.5, 2) for i in range(12)],
|
|
314
|
+
"intensity": [round(120 + 900 * math.exp(-((i - 5) ** 2) / 3.0)) for i in range(12)]}
|
|
315
|
+
cr = rpc.tool("origin_chart_render", {"name": preset_name, "columns": xrd,
|
|
316
|
+
"output_dir": OUT})
|
|
305
317
|
check("chart_render delivers image+opju", cr.get("ok") and
|
|
306
318
|
bool((cr.get("delivered") or {}).get("image")) and bool((cr.get("delivered") or {}).get("project")),
|
|
307
319
|
(cr.get("delivered"), cr.get("code"), cr.get("message")))
|
|
308
320
|
check("chart_render applied the preset titles",
|
|
309
321
|
(cr.get("preset") or {}).get("x_title") and (cr.get("preset") or {}).get("y_title"),
|
|
310
322
|
cr.get("preset"))
|
|
323
|
+
bad = rpc.tool("origin_chart_render", {"name": preset_name,
|
|
324
|
+
"source": os.path.join(HERE, "sample.dat"),
|
|
325
|
+
"x": 1, "y": 2, "output_dir": OUT})
|
|
326
|
+
check("chart_render refuses data missing the preset's columns",
|
|
327
|
+
not bad.get("ok") and bad.get("code") == "preset_columns_mismatch",
|
|
328
|
+
(bad.get("code"), bad.get("message")))
|
|
311
329
|
|
|
312
330
|
gp = rpc.tool("origin_fit", {"worksheet": ws, "x": 1, "y": 2, "kind": "gauss"})
|
|
313
331
|
check("fit preset resolves to an installed function", gp.get("ok") and gp.get("func") == "Gauss",
|
|
@@ -410,6 +428,18 @@ def main():
|
|
|
410
428
|
check("tools still work after abuse", still.get("connected") is True, still.get("code"))
|
|
411
429
|
proto.close()
|
|
412
430
|
|
|
431
|
+
# The child server processes this suite drives can be terminated instead of
|
|
432
|
+
# exiting cleanly, which leaves their Origin instance running. Reclaim only
|
|
433
|
+
# windowless instances that started after this run: an older one may belong
|
|
434
|
+
# to a live dsh session.
|
|
435
|
+
late = [r for r in originlab._process_rows()
|
|
436
|
+
if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
|
|
437
|
+
for row in late:
|
|
438
|
+
originlab._close_pid(row["pid"])
|
|
439
|
+
left = [r for r in originlab._process_rows()
|
|
440
|
+
if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
|
|
441
|
+
check("run leaves no Origin instance of its own", not left, left)
|
|
442
|
+
|
|
413
443
|
print("\nartifacts:")
|
|
414
444
|
for f in sorted(os.listdir(OUT)):
|
|
415
445
|
print(" %s (%d bytes)" % (f, os.path.getsize(os.path.join(OUT, f))))
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Checks for the Origin instance-lifecycle layer: classification and reclaim.
|
|
2
|
+
|
|
3
|
+
These run without Origin: the process table and taskkill are stubbed, so the
|
|
4
|
+
safety property that matters -- a windowed instance is never killed -- is
|
|
5
|
+
asserted against a synthetic set of instances.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
|
|
13
|
+
import originlab # noqa: E402
|
|
14
|
+
|
|
15
|
+
fails = []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check(label, cond, got=""):
|
|
19
|
+
if cond:
|
|
20
|
+
print(" ok %s" % label)
|
|
21
|
+
else:
|
|
22
|
+
print(" FAIL %s -> %r" % (label, got))
|
|
23
|
+
fails.append(label)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Fake(object):
|
|
27
|
+
"""Stands in for the process table and taskkill."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, rows):
|
|
30
|
+
self.rows = rows
|
|
31
|
+
self.killed = []
|
|
32
|
+
self.forced = []
|
|
33
|
+
|
|
34
|
+
def process_rows(self):
|
|
35
|
+
return [dict(r) for r in self.rows]
|
|
36
|
+
|
|
37
|
+
def terminate(self, pid, force=False):
|
|
38
|
+
(self.forced if force else self.killed).append(pid)
|
|
39
|
+
if force:
|
|
40
|
+
self.rows = [r for r in self.rows if r["pid"] != pid]
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def sleep(self, _seconds):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def with_fake(rows, fn):
|
|
48
|
+
fake = Fake(rows)
|
|
49
|
+
saved = (originlab._process_rows, originlab._terminate, originlab._close_pid,
|
|
50
|
+
originlab.time.sleep, set(originlab._started_pids))
|
|
51
|
+
originlab._process_rows = fake.process_rows
|
|
52
|
+
originlab._terminate = fake.terminate
|
|
53
|
+
originlab._close_pid = lambda pid: (fake.terminate(pid), fake.terminate(pid, force=True),
|
|
54
|
+
"force_closed")[2]
|
|
55
|
+
originlab.time.sleep = fake.sleep
|
|
56
|
+
try:
|
|
57
|
+
return fn(fake)
|
|
58
|
+
finally:
|
|
59
|
+
originlab._process_rows, originlab._terminate, originlab._close_pid = saved[0], saved[1], saved[2]
|
|
60
|
+
originlab.time.sleep = saved[3]
|
|
61
|
+
originlab._started_pids.clear()
|
|
62
|
+
originlab._started_pids.update(saved[4])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
BACK = {"pid": 1001, "started": "2026-10-10T12:00:00", "window": 0}
|
|
66
|
+
BACK2 = {"pid": 1002, "started": "2026-10-10T12:05:00", "window": 0}
|
|
67
|
+
FRONT = {"pid": 2001, "started": "2026-10-10T11:00:00", "window": 131072}
|
|
68
|
+
|
|
69
|
+
print("instances() 分类")
|
|
70
|
+
rows = [dict(BACK), dict(BACK2), dict(FRONT)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _classify(_fake):
|
|
74
|
+
originlab._started_pids.add(1002)
|
|
75
|
+
info = originlab.instances()
|
|
76
|
+
check("后台两个", [r["pid"] for r in info["background"]] == [1001, 1002], info["background"])
|
|
77
|
+
check("前台一个", [r["pid"] for r in info["foreground"]] == [2001], info["foreground"])
|
|
78
|
+
check("count 对", info["count"] == 3, info["count"])
|
|
79
|
+
back_by_pid = {r["pid"]: r for r in info["background"]}
|
|
80
|
+
check("标出我们起的", back_by_pid[1002]["ours"] and not back_by_pid[1001]["ours"],
|
|
81
|
+
{p: b["ours"] for p, b in back_by_pid.items()})
|
|
82
|
+
check("前台窗口标记为 foreground", info["foreground"][0]["foreground"] is True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
with_fake(rows, _classify)
|
|
86
|
+
|
|
87
|
+
print("reclaim() 只动无窗口的")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _reclaim_closes_back(_fake):
|
|
91
|
+
out = originlab.reclaim()
|
|
92
|
+
check("关掉了两个后台", sorted(_fake.forced) == [1001, 1002], _fake.forced)
|
|
93
|
+
check("没碰前台 2001", 2001 not in _fake.killed and 2001 not in _fake.forced,
|
|
94
|
+
(_fake.killed, _fake.forced))
|
|
95
|
+
check("left_running 里是前台", out["left_running"] == [2001], out["left_running"])
|
|
96
|
+
check("通知文案提到窗口", "有窗口" in out["notify"], out["notify"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
with_fake([dict(BACK), dict(BACK2), dict(FRONT)], _reclaim_closes_back)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _reclaim_dry_run(_fake):
|
|
103
|
+
out = originlab.reclaim(close_background=False)
|
|
104
|
+
check("预览模式没杀任何进程", not _fake.killed and not _fake.forced, (_fake.killed, _fake.forced))
|
|
105
|
+
check("预览模式列出了待关的", sorted(out["skipped"]) == [1001, 1002], out.get("skipped"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
with_fake([dict(BACK), dict(BACK2), dict(FRONT)], _reclaim_dry_run)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _reclaim_all_background(_fake):
|
|
112
|
+
out = originlab.reclaim()
|
|
113
|
+
check("全是后台时通知说没有前台", "没有发现前台窗口" in out["notify"], out["notify"])
|
|
114
|
+
check("left_running 为空", out["left_running"] == [], out["left_running"])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
with_fake([dict(BACK)], _reclaim_all_background)
|
|
118
|
+
|
|
119
|
+
print("_release_on_exit() 只回收自己起的")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _atexit_scope(_fake):
|
|
123
|
+
originlab._started_pids.add(1002)
|
|
124
|
+
killed = []
|
|
125
|
+
originlab._close_pid = lambda pid: killed.append(pid) or "closed"
|
|
126
|
+
originlab._release_on_exit()
|
|
127
|
+
check("只关了 1002", killed == [1002], killed)
|
|
128
|
+
check("1001 是别人的孤儿,没在退出时动它", 1001 not in killed, killed)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
with_fake([dict(BACK), dict(BACK2), dict(FRONT)], _atexit_scope)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _atexit_noop(_fake):
|
|
135
|
+
originlab._started_pids.clear()
|
|
136
|
+
killed = []
|
|
137
|
+
originlab._close_pid = lambda pid: killed.append(pid) or "closed"
|
|
138
|
+
originlab._release_on_exit()
|
|
139
|
+
check("自己没起实例时什么都不做", killed == [], killed)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
with_fake([dict(BACK), dict(FRONT)], _atexit_noop)
|
|
143
|
+
|
|
144
|
+
print("探测失败时不能谎报干净")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _probe_unavailable(_fake):
|
|
148
|
+
originlab._process_rows = lambda: None # powershell 不可用 / 超时
|
|
149
|
+
killed = []
|
|
150
|
+
originlab._close_pid = lambda pid: killed.append(pid) or "closed"
|
|
151
|
+
|
|
152
|
+
info = originlab.instances()
|
|
153
|
+
check("instances 报告探测失败", info.get("probe") == "unavailable", info)
|
|
154
|
+
check("instances 不假装数量为 0", info.get("count") is None, info.get("count"))
|
|
155
|
+
check("instances 给了下一步建议", bool(info.get("next_actions")), info.get("next_actions"))
|
|
156
|
+
|
|
157
|
+
out = originlab.reclaim()
|
|
158
|
+
check("reclaim 什么都没杀", not killed, killed)
|
|
159
|
+
check("reclaim 标出探测失败", out.get("probe") == "unavailable", out)
|
|
160
|
+
check("reclaim 不说『已回收全部』", "没关" in out.get("notify", "")
|
|
161
|
+
or "探测失败" in out.get("notify", ""), out.get("notify"))
|
|
162
|
+
|
|
163
|
+
originlab._started_pids.add(1002)
|
|
164
|
+
originlab._release_on_exit()
|
|
165
|
+
check("退出钩子在探测失败时不动手", killed == [], killed)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
with_fake([dict(BACK), dict(FRONT)], _probe_unavailable)
|
|
169
|
+
|
|
170
|
+
print("掉线重连(call 层)")
|
|
171
|
+
_real_reset = originlab._reset_connection
|
|
172
|
+
|
|
173
|
+
check("SystemError 算掉线", originlab._dead_app(
|
|
174
|
+
SystemError("<built-in function ApplicationBase_Save> returned a result with an exception set")))
|
|
175
|
+
check("originpro 报的连接失败算掉线", originlab._dead_app(
|
|
176
|
+
originlab.OriginError("connection_error", "连不上 Origin 自动化服务器:"
|
|
177
|
+
"<built-in function ApplicationBase_Path> returned a result"
|
|
178
|
+
" with an exception set")))
|
|
179
|
+
check("参数错误不算掉线", not originlab._dead_app(
|
|
180
|
+
originlab.OriginError("bad_column", "列号从 1 开始,收到 0")))
|
|
181
|
+
check("被包成 internal_error 的 SystemError 也算掉线", originlab._dead_app(
|
|
182
|
+
originlab.OriginError("internal_error",
|
|
183
|
+
"SystemError: <built-in function ApplicationBase_Save> returned a "
|
|
184
|
+
"result with an exception set")))
|
|
185
|
+
check("普通 ValueError 不算掉线", not originlab._dead_app(ValueError("识别不出分隔符")))
|
|
186
|
+
|
|
187
|
+
tries = []
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _dies_once(*a, **k):
|
|
191
|
+
tries.append(1)
|
|
192
|
+
if len(tries) == 1:
|
|
193
|
+
raise SystemError("<built-in function ApplicationBase_Save> returned a result "
|
|
194
|
+
"with an exception set")
|
|
195
|
+
return "recovered"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
resets = []
|
|
199
|
+
originlab._reset_connection = lambda: resets.append(1) or True
|
|
200
|
+
try:
|
|
201
|
+
check("掉线后自动重连并完成任务", originlab.call(_dies_once) == "recovered", tries)
|
|
202
|
+
check("只重试一次", len(tries) == 2 and len(resets) == 1, (tries, resets))
|
|
203
|
+
|
|
204
|
+
tries[:] = []
|
|
205
|
+
resety = []
|
|
206
|
+
originlab._reset_connection = lambda: resety.append(1) or False
|
|
207
|
+
try:
|
|
208
|
+
originlab.call(_dies_once)
|
|
209
|
+
check("重连不可用时不无限重试", False, "本该抛错")
|
|
210
|
+
except SystemError as exc:
|
|
211
|
+
check("重连不可用时把原始错误交出去", "ApplicationBase_Save" in str(exc), str(exc))
|
|
212
|
+
finally:
|
|
213
|
+
originlab._reset_connection = _real_reset
|
|
214
|
+
|
|
215
|
+
print("server 工具注册")
|
|
216
|
+
import server # noqa: E402
|
|
217
|
+
|
|
218
|
+
names = [t["name"] for t in server.TOOLS]
|
|
219
|
+
check("origin_instances 已注册", "origin_instances" in names, names[-3:])
|
|
220
|
+
check("origin_reclaim 已注册", "origin_reclaim" in names, names[-3:])
|
|
221
|
+
check("工具总数 27", len(names) == 27, len(names))
|
|
222
|
+
check("工具名唯一", len(set(names)) == len(names))
|
|
223
|
+
for tool in server.TOOLS:
|
|
224
|
+
if tool["name"] in ("origin_instances", "origin_reclaim"):
|
|
225
|
+
check("%s 有描述和 schema" % tool["name"],
|
|
226
|
+
bool(tool.get("description")) and isinstance(tool.get("inputSchema"), dict))
|
|
227
|
+
|
|
228
|
+
print()
|
|
229
|
+
if fails:
|
|
230
|
+
print("%d 项失败: %s" % (len(fails), ", ".join(fails)))
|
|
231
|
+
raise SystemExit(1)
|
|
232
|
+
print("all lifecycle checks passed")
|