dsh-origin-bridge 0.4.1 → 0.4.2

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/smoke_test.py CHANGED
@@ -13,12 +13,17 @@ import sys
13
13
  import threading
14
14
  import time
15
15
 
16
+ import originlab # only for the instance-table cleanup at the end; no COM until called
17
+
16
18
  HERE = os.path.dirname(os.path.abspath(__file__))
17
19
  OUT = os.path.join(HERE, "out")
18
20
  # Point this at the installed copy (…/node_modules/dsh-origin-bridge/server.py)
19
21
  # to regression-test what dsh actually runs, without littering that folder.
20
22
  SERVER = os.environ.get("ORIGIN_BRIDGE_SERVER") or os.path.join(HERE, "server.py")
21
23
  PY = sys.executable
24
+ # ISO-8601 local timestamp of this run, used to tell our own leftover Origin
25
+ # instances from a session that was already open.
26
+ STARTED_AT = time.strftime("%Y-%m-%dT%H:%M:%S")
22
27
 
23
28
 
24
29
  class Rpc:
@@ -112,11 +117,16 @@ def main():
112
117
 
113
118
  tools = rpc.request("tools/list").get("result", {}).get("tools", [])
114
119
  names = sorted(t["name"] for t in tools)
115
- check("tools/list returns 25", len(tools) == 25, len(tools))
120
+ check("tools/list returns 27", len(tools) == 27, len(tools))
121
+ check("实例治理工具已暴露", {"origin_instances", "origin_reclaim"} <= set(names),
122
+ [n for n in names if "instance" in n or "reclaim" in n])
123
+ # Tools that genuinely take no arguments are the only ones allowed an empty
124
+ # properties block; everything else must describe its inputs.
125
+ no_arg = {"origin_status", "origin_instances"}
116
126
  for t in tools:
117
- check("schema present: %s" % t["name"], bool(t.get("inputSchema", {}).get("properties")
118
- or t["name"] in ("origin_status",)),
119
- len(t.get("inputSchema", {}).get("properties", {})))
127
+ props = t.get("inputSchema", {}).get("properties") or {}
128
+ check("schema present: %s" % t["name"], bool(props) or t["name"] in no_arg, len(props))
129
+ check("无参工具确实无参: %s" % t["name"], t["name"] not in no_arg or not props, sorted(props))
120
130
  print(" %s" % ", ".join(names))
121
131
 
122
132
  # origin_read_file is pure Python, so it is checked in both modes.
@@ -410,6 +420,18 @@ def main():
410
420
  check("tools still work after abuse", still.get("connected") is True, still.get("code"))
411
421
  proto.close()
412
422
 
423
+ # The child server processes this suite drives can be terminated instead of
424
+ # exiting cleanly, which leaves their Origin instance running. Reclaim only
425
+ # windowless instances that started after this run: an older one may belong
426
+ # to a live dsh session.
427
+ late = [r for r in originlab._process_rows()
428
+ if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
429
+ for row in late:
430
+ originlab._close_pid(row["pid"])
431
+ left = [r for r in originlab._process_rows()
432
+ if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
433
+ check("run leaves no Origin instance of its own", not left, left)
434
+
413
435
  print("\nartifacts:")
414
436
  for f in sorted(os.listdir(OUT)):
415
437
  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")