@coda-console/testbed 0.0.0 → 1.2.0
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/.coda/capabilities/run-on-device.bpmn +35 -0
- package/.coda/capabilities/use-testbed.bpmn +159 -0
- package/README.md +58 -2
- package/blackscreen/BlackScreen.swift +39 -0
- package/blackscreen/build.sh +67 -0
- package/main.mjs +30 -0
- package/package.json +59 -3
- package/scripts/blackscreen.mjs +14 -0
- package/scripts/device.mjs +13 -0
- package/scripts/host-sim.mjs +60 -0
- package/scripts/panel.mjs +28 -0
- package/scripts/registry.mjs +26 -0
- package/scripts/resolve-device.mjs +12 -0
- package/scripts/run-on-device.mjs +23 -0
- package/skills/testbed/SKILL.md +38 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!-- 在测试机上执行(README.md):会话的 host-sim 为测试机交来的命令。测试机只借不排:它借的是
|
|
3
|
+
use-testbed 交给这个会话的任务所占的组 device:<设备名>,会话没在做那台设备的任务就立刻拒绝。借到后在会话的目录里、带会话的环境执行;
|
|
4
|
+
任务答复、出错或被取消时,还在执行的命令随之停下,测试机等它停完才切黑屏。命令的退出码作为输出 exit_code,交给 host-sim。
|
|
5
|
+
能力:只被调用,结果交给调用者。 -->
|
|
6
|
+
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
7
|
+
xmlns:coda="https://coda.pub/schema/bpmn/1.0" id="run-on-device-definitions" targetNamespace="https://coda.pub/plugins/testbed">
|
|
8
|
+
<process id="run-on-device" name="在测试机上执行" isExecutable="true" coda:title="{{inputs.device}}:{{inputs.label}}">
|
|
9
|
+
<extensionElements>
|
|
10
|
+
<coda:input name="spec" required="true" description="host-sim 写下的命令说明(JSON 文件的绝对路径:命令、目录、环境)" />
|
|
11
|
+
<coda:input name="device" required="true" description="设备名,如 iphone-testbed" />
|
|
12
|
+
<coda:input name="label" default="在测试机上执行" description="运行的标题:这条命令" />
|
|
13
|
+
</extensionElements>
|
|
14
|
+
<startEvent id="start" />
|
|
15
|
+
<sequenceFlow id="to-borrow" sourceRef="start" targetRef="borrow" />
|
|
16
|
+
<serviceTask id="borrow" name="借测试机" coda:action="hold-group" coda:status="借测试机 {{inputs.device}}">
|
|
17
|
+
<extensionElements>
|
|
18
|
+
<coda:param name="group" value="device:{{inputs.device}}" />
|
|
19
|
+
<coda:param name="capacity" value="1" />
|
|
20
|
+
<coda:param name="borrow" value="only" />
|
|
21
|
+
</extensionElements>
|
|
22
|
+
</serviceTask>
|
|
23
|
+
<sequenceFlow id="to-run" sourceRef="borrow" targetRef="command" />
|
|
24
|
+
<!-- exec:取消时整组收到 TERM,执行器等这个进程退出;它等命令结束再退出。 -->
|
|
25
|
+
<scriptTask id="command" name="执行" coda:checkout="false" coda:timeout="600" coda:status="在 {{inputs.device}} 上执行:{{inputs.label}}" scriptFormat="bash">
|
|
26
|
+
<extensionElements>
|
|
27
|
+
<coda:env name="SPEC" value="=inputs.spec" />
|
|
28
|
+
<coda:output name="exit_code" />
|
|
29
|
+
</extensionElements>
|
|
30
|
+
<script>exec node "$CODA_PLUGIN_PATH/scripts/run-on-device.mjs"</script>
|
|
31
|
+
</scriptTask>
|
|
32
|
+
<sequenceFlow id="ran" sourceRef="command" targetRef="end" />
|
|
33
|
+
<endEvent id="end" />
|
|
34
|
+
</process>
|
|
35
|
+
</definitions>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!-- 使用测试机(README.md):测试机的唯一入口。先从本机的设备登记查出这台设备,再排进它的组 device:<设备名>,
|
|
3
|
+
按需装上 App,把任务交给调用的会话;会话做任务期间,它用 host-sim run --kind <设备> 的命令(能力 run-on-device)借用这个组。
|
|
4
|
+
任务答复、装包或任务出错、运行被取消,都先等借去的命令停完、再切黑屏,然后放开;切不过去就请用户处理,处理之前一直占着。
|
|
5
|
+
能力:只被调用(workflow_run 或 callActivity),结果交给调用者。 -->
|
|
6
|
+
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
7
|
+
xmlns:coda="https://coda.pub/schema/bpmn/1.0" id="use-testbed-definitions" targetNamespace="https://coda.pub/plugins/testbed">
|
|
8
|
+
<message id="cancel" name="workflow.cancel" />
|
|
9
|
+
<process id="use-testbed" name="使用测试机" isExecutable="true" coda:title="{{inputs.device}}:{{inputs.task}}">
|
|
10
|
+
<extensionElements>
|
|
11
|
+
<coda:input name="device" required="true" description="设备名:它所在 Mac 的测试机插件登记的 id,如 iphone-testbed" />
|
|
12
|
+
<coda:input name="instructions" required="true" description="要会话在测试机上做什么、做完怎么答复(写进交给会话的任务)" />
|
|
13
|
+
<coda:input name="task" default="在测试机上验证" description="交给会话的任务名" />
|
|
14
|
+
<coda:input name="app" description="先装到测试机的 App(.app 或 .ipa 的绝对路径);不填就不装" />
|
|
15
|
+
</extensionElements>
|
|
16
|
+
<startEvent id="start" />
|
|
17
|
+
<sequenceFlow id="to-find" sourceRef="start" targetRef="find" />
|
|
18
|
+
<!-- 这台 Mac 登记了哪台设备叫这个名字:显示名、UDID 与给会话的说明。没有登记就到此为止,不排队。 -->
|
|
19
|
+
<scriptTask id="find" name="找测试机" coda:checkout="false" coda:timeout="1" coda:status="找测试机 {{inputs.device}}" scriptFormat="bash">
|
|
20
|
+
<extensionElements>
|
|
21
|
+
<coda:env name="DEVICE" value="=inputs.device" />
|
|
22
|
+
<coda:output name="name" />
|
|
23
|
+
<coda:output name="udid" />
|
|
24
|
+
<coda:output name="note" />
|
|
25
|
+
</extensionElements>
|
|
26
|
+
<script>node "$CODA_PLUGIN_PATH/scripts/device.mjs"</script>
|
|
27
|
+
</scriptTask>
|
|
28
|
+
<sequenceFlow id="to-testbed" sourceRef="find" targetRef="testbed" />
|
|
29
|
+
|
|
30
|
+
<!-- 占着测试机的这一段:离开它就放开,所以切黑屏在里面。 -->
|
|
31
|
+
<subProcess id="testbed" name="占用测试机">
|
|
32
|
+
<startEvent id="testbed-start" />
|
|
33
|
+
<sequenceFlow id="to-hold" sourceRef="testbed-start" targetRef="hold" />
|
|
34
|
+
<serviceTask id="hold" name="等测试机" coda:action="hold-group" coda:status="等测试机 {{find.name}}">
|
|
35
|
+
<extensionElements>
|
|
36
|
+
<coda:param name="group" value="device:{{inputs.device}}" />
|
|
37
|
+
<coda:param name="capacity" value="1" />
|
|
38
|
+
<!-- 每次使用都排队:做着上一次任务的会话再发起一次,也排在它后面,不借它的。 -->
|
|
39
|
+
<coda:param name="borrow" value="no" />
|
|
40
|
+
</extensionElements>
|
|
41
|
+
</serviceTask>
|
|
42
|
+
<sequenceFlow id="to-has-app" sourceRef="hold" targetRef="has-app" />
|
|
43
|
+
<exclusiveGateway id="has-app" default="no-app" />
|
|
44
|
+
<sequenceFlow id="with-app" sourceRef="has-app" targetRef="install">
|
|
45
|
+
<conditionExpression xsi:type="tFormalExpression">=inputs.app</conditionExpression>
|
|
46
|
+
</sequenceFlow>
|
|
47
|
+
<sequenceFlow id="no-app" sourceRef="has-app" targetRef="task" />
|
|
48
|
+
<scriptTask id="install" name="装 App" coda:checkout="false" coda:timeout="15" coda:status="把 App 装到 {{find.name}}" scriptFormat="bash">
|
|
49
|
+
<extensionElements>
|
|
50
|
+
<coda:env name="UDID" value="=find.udid" />
|
|
51
|
+
<coda:env name="APP" value="=inputs.app" />
|
|
52
|
+
</extensionElements>
|
|
53
|
+
<script>xcrun devicectl device install app --device "$UDID" "$APP"</script>
|
|
54
|
+
</scriptTask>
|
|
55
|
+
<sequenceFlow id="installed" sourceRef="install" targetRef="task" />
|
|
56
|
+
<userTask id="task" name="=inputs.task" coda:status="{{find.name}} 交给会话使用">
|
|
57
|
+
<documentation>{{inputs.instructions}}
|
|
58
|
+
|
|
59
|
+
测试机 {{find.name}}(设备名 {{inputs.device}},UDID {{find.udid}})由这个任务占着,直到答复:本会话(含子 Agent)用 host-sim run --kind {{inputs.device}} -- <命令> 操作它,命令结束不会交出设备。答复之后还在执行的命令随之停下,测试机切黑屏,再交给下一位。
|
|
60
|
+
|
|
61
|
+
{{find.note}}</documentation>
|
|
62
|
+
</userTask>
|
|
63
|
+
<sequenceFlow id="used" sourceRef="task" targetRef="black" />
|
|
64
|
+
|
|
65
|
+
<!-- 装包或任务出错、运行被取消:同样先切黑屏,再让运行以原来的原因结束。 -->
|
|
66
|
+
<boundaryEvent id="install-cancelled" attachedToRef="install"><messageEventDefinition messageRef="cancel" /></boundaryEvent>
|
|
67
|
+
<boundaryEvent id="install-failed" attachedToRef="install"><errorEventDefinition /></boundaryEvent>
|
|
68
|
+
<boundaryEvent id="task-cancelled" attachedToRef="task"><messageEventDefinition messageRef="cancel" /></boundaryEvent>
|
|
69
|
+
<boundaryEvent id="task-failed" attachedToRef="task"><errorEventDefinition /></boundaryEvent>
|
|
70
|
+
<sequenceFlow id="install-cancelled-black" sourceRef="install-cancelled" targetRef="black-stop" />
|
|
71
|
+
<sequenceFlow id="install-failed-black" sourceRef="install-failed" targetRef="black-stop" />
|
|
72
|
+
<sequenceFlow id="task-cancelled-black" sourceRef="task-cancelled" targetRef="black-stop" />
|
|
73
|
+
<sequenceFlow id="task-failed-black" sourceRef="task-failed" targetRef="black-stop" />
|
|
74
|
+
|
|
75
|
+
<scriptTask id="black" name="切黑屏" coda:checkout="false" coda:timeout="2" coda:status="{{find.name}} 切黑屏" scriptFormat="bash">
|
|
76
|
+
<extensionElements>
|
|
77
|
+
<coda:env name="DEVICE" value="=inputs.device" />
|
|
78
|
+
<coda:env name="UDID" value="=find.udid" />
|
|
79
|
+
<coda:env name="COMMANDS" value="=vars.testbed.blackscreen" />
|
|
80
|
+
</extensionElements>
|
|
81
|
+
<script>node "$CODA_PLUGIN_PATH/scripts/blackscreen.mjs"</script>
|
|
82
|
+
</scriptTask>
|
|
83
|
+
<sequenceFlow id="blacked" sourceRef="black" targetRef="done" />
|
|
84
|
+
<!-- 切黑屏时被取消:照样切完;运行随后以「已取消」结束。 -->
|
|
85
|
+
<boundaryEvent id="black-cancelled" attachedToRef="black" cancelActivity="false"><messageEventDefinition messageRef="cancel" /></boundaryEvent>
|
|
86
|
+
<sequenceFlow id="black-noted" sourceRef="black-cancelled" targetRef="noted" />
|
|
87
|
+
<endEvent id="noted" />
|
|
88
|
+
<boundaryEvent id="black-failed" attachedToRef="black"><errorEventDefinition /></boundaryEvent>
|
|
89
|
+
<sequenceFlow id="ask-black" sourceRef="black-failed" targetRef="black-trouble" />
|
|
90
|
+
<userTask id="black-trouble" name="测试机切黑屏失败" coda:assignee="user" coda:handback="false" coda:status="{{find.name}} 切黑屏失败,等你处理">
|
|
91
|
+
<documentation>测试机 {{find.name}}({{inputs.device}})用完后没能切黑屏,原因见运行页「切黑屏」这一步的日志。在你处理之前,它不会交给下一位。
|
|
92
|
+
|
|
93
|
+
检查设备连接、黑屏 App 或插件设置「切黑屏命令」后选「重试」;已经手动处理好了就选「我处理好了,放锁」。</documentation>
|
|
94
|
+
<extensionElements>
|
|
95
|
+
<coda:choice id="retry" name="重试" />
|
|
96
|
+
<coda:choice id="release" name="我处理好了,放锁" />
|
|
97
|
+
</extensionElements>
|
|
98
|
+
</userTask>
|
|
99
|
+
<sequenceFlow id="black-answered" sourceRef="black-trouble" targetRef="black-again" />
|
|
100
|
+
<exclusiveGateway id="black-again" default="black-released" />
|
|
101
|
+
<sequenceFlow id="black-retry" sourceRef="black-again" targetRef="black">
|
|
102
|
+
<conditionExpression xsi:type="tFormalExpression">=black-trouble.chose.retry</conditionExpression>
|
|
103
|
+
</sequenceFlow>
|
|
104
|
+
<sequenceFlow id="black-released" sourceRef="black-again" targetRef="done" />
|
|
105
|
+
<endEvent id="done">
|
|
106
|
+
<extensionElements><coda:output name="stopped" value="false" /></extensionElements>
|
|
107
|
+
</endEvent>
|
|
108
|
+
|
|
109
|
+
<scriptTask id="black-stop" name="切黑屏(没有做完)" coda:checkout="false" coda:timeout="2" coda:status="{{find.name}} 切黑屏" scriptFormat="bash">
|
|
110
|
+
<extensionElements>
|
|
111
|
+
<coda:env name="DEVICE" value="=inputs.device" />
|
|
112
|
+
<coda:env name="UDID" value="=find.udid" />
|
|
113
|
+
<coda:env name="COMMANDS" value="=vars.testbed.blackscreen" />
|
|
114
|
+
</extensionElements>
|
|
115
|
+
<script>node "$CODA_PLUGIN_PATH/scripts/blackscreen.mjs"</script>
|
|
116
|
+
</scriptTask>
|
|
117
|
+
<sequenceFlow id="stop-blacked" sourceRef="black-stop" targetRef="stopped" />
|
|
118
|
+
<boundaryEvent id="black-stop-cancelled" attachedToRef="black-stop" cancelActivity="false"><messageEventDefinition messageRef="cancel" /></boundaryEvent>
|
|
119
|
+
<sequenceFlow id="black-stop-noted" sourceRef="black-stop-cancelled" targetRef="stop-noted" />
|
|
120
|
+
<endEvent id="stop-noted" />
|
|
121
|
+
<boundaryEvent id="black-stop-failed" attachedToRef="black-stop"><errorEventDefinition /></boundaryEvent>
|
|
122
|
+
<sequenceFlow id="ask-black-stop" sourceRef="black-stop-failed" targetRef="black-stop-trouble" />
|
|
123
|
+
<userTask id="black-stop-trouble" name="测试机切黑屏失败" coda:assignee="user" coda:handback="false" coda:status="{{find.name}} 切黑屏失败,等你处理">
|
|
124
|
+
<documentation>测试机 {{find.name}}({{inputs.device}})没能切黑屏,原因见运行页「切黑屏(没有做完)」这一步的日志。在你处理之前,它不会交给下一位。
|
|
125
|
+
|
|
126
|
+
检查设备连接、黑屏 App 或插件设置「切黑屏命令」后选「重试」;已经手动处理好了就选「我处理好了,放锁」。</documentation>
|
|
127
|
+
<extensionElements>
|
|
128
|
+
<coda:choice id="retry" name="重试" />
|
|
129
|
+
<coda:choice id="release" name="我处理好了,放锁" />
|
|
130
|
+
</extensionElements>
|
|
131
|
+
</userTask>
|
|
132
|
+
<sequenceFlow id="black-stop-answered" sourceRef="black-stop-trouble" targetRef="black-stop-again" />
|
|
133
|
+
<exclusiveGateway id="black-stop-again" default="black-stop-released" />
|
|
134
|
+
<sequenceFlow id="black-stop-retry" sourceRef="black-stop-again" targetRef="black-stop">
|
|
135
|
+
<conditionExpression xsi:type="tFormalExpression">=black-stop-trouble.chose.retry</conditionExpression>
|
|
136
|
+
</sequenceFlow>
|
|
137
|
+
<sequenceFlow id="black-stop-released" sourceRef="black-stop-again" targetRef="stopped" />
|
|
138
|
+
<endEvent id="stopped">
|
|
139
|
+
<extensionElements><coda:output name="stopped" value="true" /></extensionElements>
|
|
140
|
+
</endEvent>
|
|
141
|
+
</subProcess>
|
|
142
|
+
|
|
143
|
+
<!-- 放开之后:做完了把任务的答复交给调用者;没做完(出错、被取消)就以原来的原因结束。 -->
|
|
144
|
+
<sequenceFlow id="to-how" sourceRef="testbed" targetRef="how" />
|
|
145
|
+
<exclusiveGateway id="how" default="to-end" />
|
|
146
|
+
<sequenceFlow id="to-not-done" sourceRef="how" targetRef="not-done">
|
|
147
|
+
<conditionExpression xsi:type="tFormalExpression">=testbed.stopped</conditionExpression>
|
|
148
|
+
</sequenceFlow>
|
|
149
|
+
<sequenceFlow id="to-end" sourceRef="how" targetRef="end" />
|
|
150
|
+
<endEvent id="end">
|
|
151
|
+
<extensionElements>
|
|
152
|
+
<coda:output name="result" value="=task.result" />
|
|
153
|
+
<coda:output name="note" value="=task.note" />
|
|
154
|
+
<coda:output name="choice" value="=task.choice" />
|
|
155
|
+
</extensionElements>
|
|
156
|
+
</endEvent>
|
|
157
|
+
<endEvent id="not-done" name="没有做完"><errorEventDefinition /></endEvent>
|
|
158
|
+
</process>
|
|
159
|
+
</definitions>
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 测试机
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
iPhone、iPad 测试机由这个插件登记归属。每台设备是一个[并发组](https://github.com/coda-console/coda/blob/main/docs/workflows.md#并发组) `device:<设备名>`,容量 1;
|
|
4
|
+
排到、装 App、交给会话、复原都写在它的能力 `use-testbed`(`.coda/capabilities/use-testbed.bpmn`)里,会话借用设备的命令走它的另一个能力 `run-on-device`。
|
|
5
|
+
它依赖[宿主资源](../host/README.md)插件(`extensionDependencies`),接在那边的扩展点 `kinds` 上:会话里 `host-sim --kind <设备名>` 由它的 `scripts/host-sim.mjs` 处理。
|
|
6
|
+
从 Coda 的商店安装(npm 包 `@coda-console/testbed`),装它时一起装上宿主资源;给 Agent 的用法在 Skill `testbed`(`skills/testbed/SKILL.md`)。
|
|
7
|
+
需要会话操作时调用 `use-testbed`。无需会话操作但需要独占设备的流程(如预览版直装、开发流程的设备验收),可自行用 `hold-group device:<id>`(capacity 1)占一段,测试机会话排在它后面;流程负责自己的操作与收尾。
|
|
8
|
+
|
|
9
|
+
**设备登记**:插件在这个后端的状态目录里的 `devices.json`([`CODA_PLUGIN_STATE`](https://github.com/coda-console/coda/blob/main/docs/plugins.md#插件进程的环境):默认实例是 `~/.local/state/coda/data/plugins/testbed/devices.json`,
|
|
10
|
+
另一个[后端实例](https://github.com/coda-console/coda/blob/main/docs/deployment.md#后端实例)是 `P/data/plugins/testbed/devices.json`)。它是一个数组,每项一台归本实例的设备:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
[{ "id": "iphone-testbed", "name": "iPhone13pm", "udid": "…", "note": "…" }]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`id` 是设备名(小写字母开头,字母、数字、连字符),`name` 显示名,`udid` 不重复;`note` 可省,是给拿到任务的会话的说明(这台 Mac 上怎么驱动这台设备、哪些东西不能碰),原样附在任务末尾。
|
|
17
|
+
登记在哪个实例就归哪个实例,`devices.json` 是归属的唯一事实来源。插件启动时声明 `device:<id>`,把 `name`、`udid`、`note` 发布到空间,以 `udid` 为唯一属性;文件变化时重新全量声明。空间里同一设备只能登记一次,同名或相同 UDID 的重复登记会使占用失败并报冲突。
|
|
18
|
+
登记读不了时日志记录错误,保留已有声明;有效的空数组 `[]` 撤销全部声明。设备正被使用时不要改名或换 UDID。
|
|
19
|
+
|
|
20
|
+
任何实例的会话都能用设备,由引擎在归属实例排队。安装、会话的 `host-sim` 命令和切黑屏在发起的 Mac 上执行;这台 Mac 须与设备配对,跑 XCTest 还须有本机 runner。
|
|
21
|
+
|
|
22
|
+
**入口**:
|
|
23
|
+
- Agent:`workflow_run`,`workflow: "use-testbed"`,`inputs` 给 `device`(设备名,归属实例登记的 `id`)、
|
|
24
|
+
`instructions`(要会话在测试机上做什么、怎么答复),可选 `app`(先装上的 `.app` 或 `.ipa` 的绝对路径)、`task`(任务名,默认「在测试机上验证」)。插件的说明告诉每个会话的 Agent 这样调用。
|
|
25
|
+
- 其他流程:`<callActivity calledElement="use-testbed">`,参数用 `coda:param`;项目流程先在自己的步骤构建、签名,把 App 路径传给它。
|
|
26
|
+
|
|
27
|
+
**过程**:
|
|
28
|
+
1. 「找测试机」:先查本地登记;没有这个 id 就用 `coda-resources group-owner device:<id>` 读取归属实例声明的显示名、UDID 和说明。两边都没有则报「没有这台测试机」,列出本地可用设备,不排队。
|
|
29
|
+
2. 「等测试机」:`hold-group` 为「占用测试机」这一段排进组 `device:<设备名>`(`capacity` 1、`borrow="no"`:每次使用都排队,做着上一次任务的会话再发起一次也排在后面),由引擎到归属实例排队,排到才往下走;之后到这一段结束都占着它。
|
|
30
|
+
3. 有 `app` 就装上(`xcrun devicectl device install app`),再把任务交给调用的会话:要求是 `instructions`,加上设备名、UDID、借用方法和登记里的说明。
|
|
31
|
+
做任务期间,这个会话(含子 Agent)用 `host-sim run --kind <设备名> -- <命令>` 操作设备:`host-sim` 经 `coda-run` 把命令交给能力 `run-on-device`,
|
|
32
|
+
它用 `hold-group borrow="only"` 借这个组——只借给正在做这台设备任务的会话,别的情况立刻拒绝、不排队——借到后在会话的目录里、带会话的环境执行,
|
|
33
|
+
附上 `HOST_SIM_LEASE=coda-run:<设备名>`(命令里再调用 `host-sim run --kind <设备名>` 直接执行,`host-sim check` 通过),退出码交回 `host-sim`。
|
|
34
|
+
任务答复、撤回或运行被取消时,还在执行的这些命令随之停下([并发组](https://github.com/coda-console/coda/blob/main/docs/workflows.md#并发组)「借用跟着任务走」),下一步等它们停完才开始。
|
|
35
|
+
4. 「切黑屏」:任务答复、借用的命令停完后,在发起的 Mac 上用 `xcrun devicectl device process launch --terminate-existing` 在设备上打开[黑屏 App](#黑屏-app),限时 2 分钟;
|
|
36
|
+
设置「切黑屏命令」(`testbed.blackscreen`)里有这台设备的一行时,改为用 `sh` 执行那一行,设备的 UDID 在环境变量 `UDID` 里。
|
|
37
|
+
装 App 或任务出错、任务被拒绝、运行被取消(流程等着 [`workflow.cancel`](https://github.com/coda-console/coda/blob/main/docs/workflows.md#执行与恢复))时,同样先切黑屏;切黑屏时被取消,照样切完。
|
|
38
|
+
5. 切不过去(命令失败或超时,如设备断开、锁屏或没装黑屏 App):给用户一张卡片「测试机切黑屏失败」,选「重试」或「我处理好了,放锁」;处理之前设备一直占着,不交给下一位。
|
|
39
|
+
卡片没有主按钮。
|
|
40
|
+
6. 离开「占用测试机」这一段才放开组,排在后面的随即拿到。
|
|
41
|
+
|
|
42
|
+
**结果**:做完了,`result`、`note`、`choice` 是任务的答复,调用者照常读到;没做完时以原来的原因结束(被拒绝时是拒绝的理由),被取消的记为已取消。
|
|
43
|
+
|
|
44
|
+
**设置**:`testbed.blackscreen`「切黑屏命令」是表格,默认没有行;某台设备要换掉默认的做法时加一行:左边是设备名,右边是在发起的 Mac 上执行的命令。
|
|
45
|
+
设置在整个空间只有一份,所以各台 Mac 的设备写在同一张表里,设备名在空间里不重复。
|
|
46
|
+
|
|
47
|
+
**面板**「测试机」(`devices`):这台 Mac 登记的每台设备一张卡片(显示名、设备名),写谁在用(`use-testbed` 的运行与会话、会话借用的命令)、
|
|
48
|
+
用了多久、谁在排队;没人用写「空闲」。读的是设备登记和 `CODA_GROUPS_STATE` 里的 `device:<设备名>`;本机没有登记设备就不显示。
|
|
49
|
+
|
|
50
|
+
- 装上后对这台机器的所有项目生效([安装、停用与卸载](https://github.com/coda-console/coda/blob/main/docs/plugins.md#安装停用与卸载)),可按 Tag 限定或在项目里停用。
|
|
51
|
+
|
|
52
|
+
## 黑屏 App
|
|
53
|
+
|
|
54
|
+
插件的 `blackscreen/BlackScreen.swift`:纯黑全屏、隐藏状态栏与主屏幕指示条的 App,iPhone、iPad 通用(OLED 黑色像素不发光;LCD 的 iPad 在它前台时把亮度压到最低,离开时还原)。
|
|
55
|
+
Bundle ID 是 `com.leavan.blackscreen`,切黑屏默认打开的就是它。每台测试机装一次;签名用的开发描述文件到期或换设备时重装:
|
|
56
|
+
|
|
57
|
+
1. 在本仓库工作树的会话里,用持锁执行(`run-held`)运行 `sh plugins/testbed/blackscreen/build.sh <输出目录> <描述文件名>`,占一个构建名额;
|
|
58
|
+
编译后用登录钥匙串里的 Apple Development 证书和这份开发描述文件签名(描述文件须是通配 App ID、包含目标设备,Team 取自描述文件),得到 `<输出目录>/BlackScreen.app`。它不碰设备。
|
|
59
|
+
2. 在与设备配对的 Mac 上调用 `use-testbed`,`app` 给这个 `.app`:装上后任务交给会话,答复后的切黑屏打开它,切得过去就是装好了。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
// 纯黑全屏:测试机用完交出前切到这里(插件「测试机」的切黑屏),空闲时停着;OLED 黑色像素不发光,不烧屏。
|
|
4
|
+
// LCD(iPad)黑色仍有背光:在前台时把亮度压到最低,离开时还原,别的 App 和截图不受影响。
|
|
5
|
+
final class BlackViewController: UIViewController {
|
|
6
|
+
override func loadView() {
|
|
7
|
+
view = UIView()
|
|
8
|
+
view.backgroundColor = .black
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
override var prefersStatusBarHidden: Bool { true }
|
|
12
|
+
override var prefersHomeIndicatorAutoHidden: Bool { true }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@main
|
|
16
|
+
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
17
|
+
var window: UIWindow?
|
|
18
|
+
private var savedBrightness: CGFloat?
|
|
19
|
+
|
|
20
|
+
func application(_ application: UIApplication,
|
|
21
|
+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
|
22
|
+
window = UIWindow(frame: UIScreen.main.bounds)
|
|
23
|
+
window?.rootViewController = BlackViewController()
|
|
24
|
+
window?.makeKeyAndVisible()
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func applicationDidBecomeActive(_ application: UIApplication) {
|
|
29
|
+
savedBrightness = savedBrightness ?? UIScreen.main.brightness
|
|
30
|
+
UIScreen.main.brightness = 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func applicationWillResignActive(_ application: UIApplication) {
|
|
34
|
+
if let saved = savedBrightness {
|
|
35
|
+
UIScreen.main.brightness = saved
|
|
36
|
+
savedBrightness = nil
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# 编译并签名黑屏 App(iPhone 与 iPad 通用),不装到设备:装经测试机插件的 use-testbed(app 给产物路径),见 README.md。
|
|
3
|
+
# 用法:build.sh <输出目录> <描述文件名>。签名用登录钥匙串里的 Apple Development 证书与这份开发描述文件(须是通配 App ID、包含目标设备),
|
|
4
|
+
# Team 取自描述文件。在 Coda 会话里交给持锁执行(run-held)运行,或在占着组 host 的流程步骤里:它经 host-sim 占构建名额。
|
|
5
|
+
set -eu
|
|
6
|
+
[ $# -eq 2 ] || { echo "用法:$0 <输出目录> <描述文件名>" >&2; exit 2; }
|
|
7
|
+
# 已在构建名额里才直接编译;测试机上执行的命令也带 HOST_SIM_LEASE,但占的不是构建名额,所以问 host-sim。
|
|
8
|
+
if ! host-sim check --kind build 2>/dev/null; then
|
|
9
|
+
exec host-sim run --kind build -- sh "$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" "$@"
|
|
10
|
+
fi
|
|
11
|
+
# blackscreen.mjs 默认打开的就是这个 Bundle ID。
|
|
12
|
+
BUNDLE_ID=com.leavan.blackscreen
|
|
13
|
+
PROFILE_NAME="$2"
|
|
14
|
+
mkdir -p "$1"
|
|
15
|
+
APP="$(cd "$1" && pwd)/BlackScreen.app"
|
|
16
|
+
WORK="$(mktemp -d)"
|
|
17
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
18
|
+
rm -rf "$APP"; mkdir -p "$APP"
|
|
19
|
+
|
|
20
|
+
profile=""
|
|
21
|
+
for candidate in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"/*.mobileprovision; do
|
|
22
|
+
security cms -D -i "$candidate" > "$WORK/profile.plist" 2>/dev/null || continue
|
|
23
|
+
if [ "$(plutil -extract Name raw -o - "$WORK/profile.plist")" = "$PROFILE_NAME" ]; then profile="$candidate"; break; fi
|
|
24
|
+
done
|
|
25
|
+
[ -n "$profile" ] || { echo "找不到描述文件:$PROFILE_NAME" >&2; exit 1; }
|
|
26
|
+
TEAM="$(plutil -extract TeamIdentifier.0 raw -o - "$WORK/profile.plist")"
|
|
27
|
+
identity=$(security find-identity -v -p codesigning | awk '/Apple Development/ {print $2; exit}')
|
|
28
|
+
[ -n "$identity" ] || { echo "登录钥匙串里没有 Apple Development 证书" >&2; exit 1; }
|
|
29
|
+
|
|
30
|
+
xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -O -parse-as-library \
|
|
31
|
+
"$(dirname "$0")/BlackScreen.swift" -o "$APP/BlackScreen"
|
|
32
|
+
|
|
33
|
+
cat > "$APP/Info.plist" <<PLIST
|
|
34
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
36
|
+
<plist version="1.0"><dict>
|
|
37
|
+
<key>CFBundleIdentifier</key><string>$BUNDLE_ID</string>
|
|
38
|
+
<key>CFBundleExecutable</key><string>BlackScreen</string>
|
|
39
|
+
<key>CFBundleName</key><string>黑屏</string>
|
|
40
|
+
<key>CFBundleDisplayName</key><string>黑屏</string>
|
|
41
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
42
|
+
<key>CFBundleVersion</key><string>1</string>
|
|
43
|
+
<key>CFBundleShortVersionString</key><string>1.0</string>
|
|
44
|
+
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
|
45
|
+
<key>MinimumOSVersion</key><string>17.0</string>
|
|
46
|
+
<key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
|
|
47
|
+
<key>UIRequiresFullScreen</key><true/>
|
|
48
|
+
<key>UIRequiredDeviceCapabilities</key><array><string>arm64</string></array>
|
|
49
|
+
<key>UILaunchScreen</key><dict/>
|
|
50
|
+
<key>UIUserInterfaceStyle</key><string>Dark</string>
|
|
51
|
+
<key>UIStatusBarHidden</key><true/>
|
|
52
|
+
</dict></plist>
|
|
53
|
+
PLIST
|
|
54
|
+
cp "$profile" "$APP/embedded.mobileprovision"
|
|
55
|
+
|
|
56
|
+
cat > "$WORK/entitlements.plist" <<PLIST
|
|
57
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
58
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
59
|
+
<plist version="1.0"><dict>
|
|
60
|
+
<key>application-identifier</key><string>$TEAM.$BUNDLE_ID</string>
|
|
61
|
+
<key>com.apple.developer.team-identifier</key><string>$TEAM</string>
|
|
62
|
+
<key>get-task-allow</key><true/>
|
|
63
|
+
</dict></plist>
|
|
64
|
+
PLIST
|
|
65
|
+
codesign --force --sign "$identity" --entitlements "$WORK/entitlements.plist" --timestamp=none "$APP"
|
|
66
|
+
codesign --verify --strict "$APP"
|
|
67
|
+
echo "$APP"
|
package/main.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { watchFile, unwatchFile } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { devices } from './scripts/registry.mjs';
|
|
4
|
+
|
|
5
|
+
let stopWatching;
|
|
6
|
+
let pending = Promise.resolve();
|
|
7
|
+
export async function activate(coda) {
|
|
8
|
+
const file = join(coda.plugin.state, 'devices.json');
|
|
9
|
+
const publish = () => {
|
|
10
|
+
pending = pending.then(async () => {
|
|
11
|
+
const list = devices(file, { missingIsEmpty: false });
|
|
12
|
+
await coda.resources.declareGroups(list.map(device => ({
|
|
13
|
+
name: `device:${device.id}`,
|
|
14
|
+
attributes: { name: device.name ?? device.id, udid: device.udid, note: device.note ?? '' },
|
|
15
|
+
unique: ['udid'],
|
|
16
|
+
})));
|
|
17
|
+
}).catch(error => console.error(`测试机归属声明失败:${error.message}`));
|
|
18
|
+
return pending;
|
|
19
|
+
};
|
|
20
|
+
// Stat-based watching also observes atomic replacements and a deleted/recreated file.
|
|
21
|
+
watchFile(file, { interval: 500 }, publish);
|
|
22
|
+
stopWatching = () => unwatchFile(file, publish);
|
|
23
|
+
await publish();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function deactivate() {
|
|
27
|
+
stopWatching?.();
|
|
28
|
+
stopWatching = undefined;
|
|
29
|
+
await pending;
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coda-console/testbed",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
3
|
+
"id": "testbed",
|
|
4
|
+
"displayName": "测试机",
|
|
5
|
+
"description": "iPhone、iPad 测试机登记在哪个实例就归哪个实例,空间里同一设备只登记一次,重复占用报冲突。任何实例的会话都能用,在归属实例排队;安装、操作和切黑屏在发起的 Mac 执行,须与设备配对,XCTest 须有本机 runner。无需会话操作的流程可自行 hold-group device:<id>(capacity 1)独占。",
|
|
6
|
+
"version": "1.2.0",
|
|
7
|
+
"categories": [
|
|
8
|
+
"机器与资源"
|
|
9
|
+
],
|
|
10
|
+
"extensionDependencies": [
|
|
11
|
+
"host"
|
|
12
|
+
],
|
|
13
|
+
"contributes": {
|
|
14
|
+
"panels": [
|
|
15
|
+
{
|
|
16
|
+
"id": "devices",
|
|
17
|
+
"title": "测试机",
|
|
18
|
+
"command": "node \"$CODA_PLUGIN_PATH/scripts/panel.mjs\""
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"capabilities": [
|
|
22
|
+
"use-testbed",
|
|
23
|
+
"run-on-device"
|
|
24
|
+
],
|
|
25
|
+
"extensions": {
|
|
26
|
+
"host/kinds": {
|
|
27
|
+
"command": "\"$CODA_NODE\" \"$CODA_PLUGIN_PATH/scripts/host-sim.mjs\""
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"instructions": "要在 iPhone/iPad 测试机上操作或验证:调用 workflow_run,workflow 为 use-testbed,inputs 给 device(设备名,如 iphone-testbed)和 instructions;先装 App 就再给 app(.app 的绝对路径)。拿到任务后用 host-sim run --kind <设备名> 操作,做完用 workflow_task 答复。",
|
|
31
|
+
"configuration": {
|
|
32
|
+
"title": "测试机",
|
|
33
|
+
"properties": {
|
|
34
|
+
"testbed.blackscreen": {
|
|
35
|
+
"title": "切黑屏命令",
|
|
36
|
+
"type": "object",
|
|
37
|
+
"order": 1,
|
|
38
|
+
"description": "每台测试机用完、交给下一位之前怎么切黑屏。默认用 devicectl 在设备上打开黑屏 App(com.leavan.blackscreen);某台要换做法就加一行:左边是设备名(归属实例的测试机插件登记的 id),右边是在发起的 Mac 上执行的命令(sh,设备的 UDID 在 $UDID)。失败时请你处理后再交出设备。",
|
|
39
|
+
"additionalProperties": {
|
|
40
|
+
"type": "string"
|
|
41
|
+
},
|
|
42
|
+
"default": {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"skills": [
|
|
47
|
+
"skills/testbed"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
5
50
|
"license": "MIT",
|
|
6
51
|
"repository": {
|
|
7
52
|
"type": "git",
|
|
@@ -10,5 +55,16 @@
|
|
|
10
55
|
},
|
|
11
56
|
"publishConfig": {
|
|
12
57
|
"access": "public"
|
|
13
|
-
}
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
".coda",
|
|
61
|
+
"scripts",
|
|
62
|
+
"blackscreen",
|
|
63
|
+
"skills",
|
|
64
|
+
"main.mjs"
|
|
65
|
+
],
|
|
66
|
+
"main": "main.mjs",
|
|
67
|
+
"activationEvents": [
|
|
68
|
+
"onStartupFinished"
|
|
69
|
+
]
|
|
14
70
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Puts a test device to its black screen before it is handed on (README.md): the black screen app
|
|
2
|
+
// (blackscreen/, installed on the device) launched over devicectl, or the line the plugin's setting `testbed.blackscreen`
|
|
3
|
+
// gives for the device instead, run by sh with its UDID in `UDID`. Its output is the step's log; its exit, the step's.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// As blackscreen/build.sh signs it.
|
|
7
|
+
const APP = 'com.leavan.blackscreen';
|
|
8
|
+
const command = JSON.parse(process.env.COMMANDS || '{}')[process.env.DEVICE]?.trim()
|
|
9
|
+
|| `xcrun devicectl device process launch --terminate-existing --device "$UDID" ${APP}`;
|
|
10
|
+
console.log(`切黑屏:${command}`);
|
|
11
|
+
const child = spawn('/bin/sh', ['-c', command], { stdio: 'inherit' });
|
|
12
|
+
for (const name of ['SIGTERM', 'SIGINT']) process.on(name, () => child.kill(name));
|
|
13
|
+
child.on('error', error => { console.error(error.message); process.exit(1); });
|
|
14
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Find a device's name, UDID and note for the step outputs. Registration determines its owning instance,
|
|
2
|
+
// whose declarations are published to the space. An id absent locally is resolved through group-owner;
|
|
3
|
+
// if neither has it, report the missing device and the locally available ids.
|
|
4
|
+
import { appendFileSync } from 'node:fs';
|
|
5
|
+
import { resolveDevice } from './resolve-device.mjs';
|
|
6
|
+
|
|
7
|
+
const device = process.env.DEVICE ?? '';
|
|
8
|
+
let found;
|
|
9
|
+
try { found = resolveDevice(device); } catch (error) { fail(error.message); }
|
|
10
|
+
console.log(`测试机 ${device}:${found.name ?? device}(${found.udid})`);
|
|
11
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `name=${String(found.name ?? device).replace(/\n/g, ' ')}\nudid=${found.udid}\nnote<<CODA_NOTE\n${found.note ?? ''}\nCODA_NOTE\n`);
|
|
12
|
+
|
|
13
|
+
function fail(message) { console.error(message); process.exit(1); }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What this plugin plugs into the host plugin's `host-sim` (its point `kinds`, README.md):
|
|
2
|
+
// `host-sim run|check --kind <设备名> …` for a test device, handed over whole. In a session doing that device's task, a
|
|
3
|
+
// command is handed to the capability 在测试机上执行 and followed with `coda-run`: Coda lends it the device the task holds
|
|
4
|
+
// and runs it in the session's directory, with its environment. Inside that command (`HOST_SIM_LEASE=coda-run:<设备名>`),
|
|
5
|
+
// or in a step whose run holds the device's group (`CODA_GROUPS` names device:<设备名>), it runs at once.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { constants as system, tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2), command = args[0];
|
|
13
|
+
const option = name => { const at = args.indexOf(name); return at >= 0 ? args[at + 1] : undefined; };
|
|
14
|
+
// A step names its session too; what it may run is what its groups (`CODA_GROUPS`) hold, never a device asked for the session.
|
|
15
|
+
const kind = option('--kind'), thread = process.env.CODA_GROUPS === undefined && process.env.CODA_THREAD_ID, group = `device:${kind}`;
|
|
16
|
+
// What Coda gives this command as the plugin's (Coda's docs/plugins.md「插件进程的环境」) is not what the session's commands run with.
|
|
17
|
+
const session = () => Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^CODA_(NODE|ARBITER|PLUGIN_.*)$/.test(name)));
|
|
18
|
+
const lease = process.env.HOST_SIM_LEASE ?? '';
|
|
19
|
+
const stepHeld = (process.env.CODA_GROUPS ?? '').split(' ').includes(group);
|
|
20
|
+
const held = stepHeld || lease === `coda-run:${kind}`;
|
|
21
|
+
|
|
22
|
+
if (command === 'run' && (held || thread)) {
|
|
23
|
+
const at = args.indexOf('--'), argv = at >= 0 ? args.slice(at + 1) : [];
|
|
24
|
+
if (!argv.length) fail('用法:host-sim run --kind <设备名> -- <命令> [参数…]', 2);
|
|
25
|
+
// Already inside a command on the device (a project script calling itself again): it is this one's.
|
|
26
|
+
if (held) follow(argv, { ...session(), HOST_SIM_LEASE: lease || `coda-group:${group}` });
|
|
27
|
+
else {
|
|
28
|
+
// What to run, where and with what, for 在测试机上执行 to read: kept while the run may still read it. coda-run ending
|
|
29
|
+
// with any status but 75 (Coda's docs/workflows.md「从命令行调用」) says the run is over or never started — the device
|
|
30
|
+
// not lent, say —, so it goes then; let go of (75, or a signal), the run goes on and reads it however late that is.
|
|
31
|
+
const env = session();
|
|
32
|
+
delete env.HOST_SIM_LEASE; delete env.HOST_SIM_DEVICES;
|
|
33
|
+
const dir = join(process.env.TMPDIR || tmpdir(), 'coda-host-sim');
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
const spec = join(dir, `${randomUUID()}.json`);
|
|
36
|
+
writeFileSync(spec, JSON.stringify({ argv, cwd: process.cwd(), env, kind }), { mode: 0o600 });
|
|
37
|
+
follow(['coda-run', 'run-on-device', `spec=${spec}`, `device=${kind}`, `label=${argv.join(' ').slice(0, 200)}`], session(),
|
|
38
|
+
code => { if (code !== null && code !== 75) rmSync(spec, { force: true }); });
|
|
39
|
+
}
|
|
40
|
+
} else if (command === 'check' && held) {
|
|
41
|
+
process.stderr.write(`host-sim:在借到的测试机 ${kind} 上\n`);
|
|
42
|
+
} else if (!['run', 'check'].includes(command)) {
|
|
43
|
+
fail(`用法:host-sim run --kind ${kind} -- <命令>,或在借到的测试机上用 check`, 2);
|
|
44
|
+
} else if (thread) {
|
|
45
|
+
fail(`不在测试机 ${kind} 里:先用 host-sim run --kind ${kind} -- <命令>`, 1);
|
|
46
|
+
} else if (process.env.CODA_GROUPS !== undefined) {
|
|
47
|
+
fail(`这一步没有占着组 ${group}:测试机调用能力 use-testbed`, 1);
|
|
48
|
+
} else {
|
|
49
|
+
fail('只能在 Coda 的会话或流程步骤里用:测试机由 Coda 借出,终端里直接运行不借,也不执行', 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Runs it in the foreground, ending as it ends; a signal to this process goes on to it (coda-run then lets the run go on).
|
|
53
|
+
// `ended` is told its exit code first: 127 when it could not be started, null when a signal ended it.
|
|
54
|
+
function follow([program, ...rest], env, ended = () => {}) {
|
|
55
|
+
const child = spawn(program, rest, { stdio: 'inherit', env });
|
|
56
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
57
|
+
child.on('error', error => { ended(127); fail(error.message, 127); });
|
|
58
|
+
child.on('close', (code, signal) => { ended(code); process.exit(code ?? 128 + (system.signals[signal] ?? 0)); });
|
|
59
|
+
}
|
|
60
|
+
function fail(message, code) { process.stderr.write(`host-sim:${message}\n`); process.exit(code); }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The test devices plugged into this Mac as a panel (README.md): each one registered here
|
|
2
|
+
// (scripts/registry.mjs), with who has it — its group device:<name>, as Coda says it is now (`CODA_GROUPS_STATE`) — and who
|
|
3
|
+
// waits. No device registered, nothing to show.
|
|
4
|
+
import { devices } from './registry.mjs';
|
|
5
|
+
|
|
6
|
+
const { now, groups } = JSON.parse(process.env.CODA_GROUPS_STATE || '{"groups":[]}');
|
|
7
|
+
let list;
|
|
8
|
+
try { list = devices(); } catch (error) {
|
|
9
|
+
console.log(JSON.stringify({ items: [{ type: 'error', text: error.message.slice(0, 200) }] })); process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const clip = (text, n = 80) => text.length > n ? `${text.slice(0, n - 1)}…` : text;
|
|
13
|
+
const minutes = since => { const m = Math.max(0, Math.floor((now - since) / 60_000)); return m < 1 ? '不到 1 分钟' : m < 60 ? `${m} 分钟` : `${Math.floor(m / 60)} 小时 ${m % 60} 分钟`; };
|
|
14
|
+
const who = x => clip(`${x.title || x.workflow}${x.thread ? ` · ${x.thread}` : ''}`);
|
|
15
|
+
|
|
16
|
+
const items = list.filter(d => d && typeof d.id === 'string').slice(0, 20).map(device => {
|
|
17
|
+
const group = groups.find(g => g.name === `device:${device.id}`) ?? { holders: [], queue: [] };
|
|
18
|
+
const rows = [];
|
|
19
|
+
// The use that holds it first; commands a session borrowed it for after.
|
|
20
|
+
const holders = [...group.holders].sort((a, b) => a.borrowed - b.borrowed || a.since - b.since);
|
|
21
|
+
if (!holders.length) rows.push({ type: 'stat', label: '状态', value: '空闲' });
|
|
22
|
+
for (const h of holders.slice(0, 8))
|
|
23
|
+
rows.push({ type: 'stat', label: who(h), value: clip(h.leaving ? `正在收尾,${minutes(h.since)}` : h.borrowed ? `会话借用 ${minutes(h.since)}` : `使用中 ${minutes(h.since)}`) });
|
|
24
|
+
for (const [i, w] of group.queue.slice(0, 8).entries())
|
|
25
|
+
rows.push({ type: 'stat', label: who(w), value: `排第 ${i + 1} 位,等了 ${minutes(w.since)}` });
|
|
26
|
+
return { type: 'card', title: clip(device.name || device.id, 40), subtitle: clip(device.id), items: rows };
|
|
27
|
+
});
|
|
28
|
+
console.log(JSON.stringify({ items }));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Devices belong to the instance whose plugin state contains their devices.json registration.
|
|
2
|
+
// The plugin publishes ownership declarations to the space; callers missing a local id query group-owner.
|
|
3
|
+
// An unreadable registration must not revoke existing declarations; only a valid empty array does.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const file = process.env.CODA_PLUGIN_STATE && join(process.env.CODA_PLUGIN_STATE, 'devices.json');
|
|
8
|
+
export function devices(path = file, { missingIsEmpty = true } = {}) {
|
|
9
|
+
let list;
|
|
10
|
+
try { list = JSON.parse(readFileSync(path, 'utf8')); } catch (error) {
|
|
11
|
+
if (missingIsEmpty && error.code === 'ENOENT') return [];
|
|
12
|
+
throw new Error(`读不了设备登记 ${path}:${error.message}`);
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(list)) throw new Error(`设备登记 ${path} 须为设备数组`);
|
|
15
|
+
for (const device of list) {
|
|
16
|
+
if (typeof device?.udid !== 'string' || !device.udid)
|
|
17
|
+
throw new Error(`设备登记 ${path} 须有非空 udid`);
|
|
18
|
+
}
|
|
19
|
+
// A device is held by the group of its name: one device under two names would be held twice, one name twice either device.
|
|
20
|
+
for (const key of ['id', 'udid']) {
|
|
21
|
+
const values = list.map(d => d?.[key]).filter(Boolean);
|
|
22
|
+
const twice = [...new Set(values.filter((value, i) => values.indexOf(value) !== i))];
|
|
23
|
+
if (twice.length) throw new Error(`设备登记 ${path} 里 ${key} 重复:${twice.join('、')}`);
|
|
24
|
+
}
|
|
25
|
+
return list;
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Local registration is authoritative here; otherwise read the owner's published attributes.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { devices } from './registry.mjs';
|
|
4
|
+
|
|
5
|
+
export function resolveDevice(id) {
|
|
6
|
+
const list = devices();
|
|
7
|
+
const local = list.find(device => device.id === id);
|
|
8
|
+
if (local) return local;
|
|
9
|
+
const owner = JSON.parse(execFileSync('coda-resources', ['group-owner', `device:${id}`], { encoding: 'utf8' }));
|
|
10
|
+
if (owner) return { id, ...owner.attributes };
|
|
11
|
+
throw new Error(`没有这台测试机 ${id};本地可用:${list.map(device => device.id).join('、') || '无'}`);
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The step of 在测试机上执行 that runs the command a session's host-sim handed over for a test device (scripts/host-sim.mjs),
|
|
2
|
+
// once the device is lent to it: in the session's directory, with the session's environment, as if host-sim had run it
|
|
3
|
+
// there. Its commands see the device as theirs (`HOST_SIM_LEASE`), so that a project script calling host-sim again for
|
|
4
|
+
// it inside runs at once. The command's exit status is the step's output `exit_code`; the step itself succeeds, so that
|
|
5
|
+
// the status reaches host-sim as the command gave it.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { appendFileSync, readFileSync, rmSync } from 'node:fs';
|
|
8
|
+
import { constants } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const spec = JSON.parse(readFileSync(process.env.SPEC, 'utf8'));
|
|
11
|
+
rmSync(process.env.SPEC, { force: true });
|
|
12
|
+
const env = { ...spec.env, HOST_SIM_LEASE: `coda-run:${spec.kind}` };
|
|
13
|
+
const child = spawn(spec.argv[0], spec.argv.slice(1), { cwd: spec.cwd, env, stdio: 'inherit' });
|
|
14
|
+
let ending = false;
|
|
15
|
+
// Cancelled (its task over, say): the step's process group is asked to stop, the command with it.
|
|
16
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { ending = true; });
|
|
17
|
+
child.on('error', error => { process.stderr.write(`执行不了:${error.message}\n`); done(127); });
|
|
18
|
+
child.on('close', (code, signal) => done(code ?? 128 + (constants.signals[signal] ?? 0)));
|
|
19
|
+
|
|
20
|
+
function done(status) {
|
|
21
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `exit_code=${status}\n`);
|
|
22
|
+
process.exit(ending ? 143 : 0);
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testbed
|
|
3
|
+
description: 测试机插件怎么用:会话或流程里用 use-testbed 排到 iPhone/iPad 测试机、按需装 App、拿到任务后用 host-sim run --kind <设备名> 操作并用 workflow_task 答复;流程里 callActivity 的写法与输出。要在真机上操作、验证或写真机验收流程时读。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 测试机
|
|
7
|
+
|
|
8
|
+
测试机(`iphone-testbed`、`ipad-testbed` 这类设备)需要会话操作时经能力 `use-testbed` 使用:它排进设备的并发组 `device:<设备名>`,
|
|
9
|
+
按需装 App,把任务交给发起的会话;答复、出错或运行被取消后,先等会话借设备还在跑的命令停下,再切黑屏、交出设备。
|
|
10
|
+
|
|
11
|
+
登记在哪个实例就归哪个实例,`devices.json` 是归属的唯一事实来源。空间里同一设备只能登记一次,同名或相同 UDID 的重复登记会使占用失败并报冲突。任何实例的会话都能用,在归属实例排队。
|
|
12
|
+
安装、会话的 host-sim 命令和切黑屏在发起的 Mac 上跑;这台 Mac 须与设备配对,跑 XCTest 还须有本机 runner。
|
|
13
|
+
|
|
14
|
+
## 会话里
|
|
15
|
+
|
|
16
|
+
1. 调用 `workflow_run`,`workflow` 为 `use-testbed`,`inputs` 给 `device`(设备名)和 `instructions`(要做什么、怎么答复);
|
|
17
|
+
先装 App 就再给 `app`(`.app` 或 `.ipa` 的绝对路径),`task` 是任务名(默认「在测试机上验证」)。
|
|
18
|
+
2. 排到后你会收到任务:用 `host-sim run --kind <设备名> -- <命令>` 操作设备(如 `xcrun devicectl …`)。
|
|
19
|
+
只有正在做这台设备任务的会话(含子会话)借得到,别的时候立刻拒绝、不排队。
|
|
20
|
+
3. 做完用 `workflow_task` 答复;答复后还在跑的借用命令随之停下。
|
|
21
|
+
|
|
22
|
+
## 流程里
|
|
23
|
+
|
|
24
|
+
先在自己的步骤构建、签名,把产物放进 `$CODA_ARTIFACTS`,再调用它:
|
|
25
|
+
|
|
26
|
+
```xml
|
|
27
|
+
<callActivity id="verify" name="真机验证" calledElement="use-testbed">
|
|
28
|
+
<extensionElements>
|
|
29
|
+
<coda:param name="device" value="iphone-testbed" />
|
|
30
|
+
<coda:param name="app" value="=sign.app" />
|
|
31
|
+
<coda:param name="instructions" value="打开 App,走一遍登录,截图确认没有报错。" />
|
|
32
|
+
</extensionElements>
|
|
33
|
+
</callActivity>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
任务的答复是这一步的输出:`=verify.result`、`.note`、`.choice`。被拒绝、出错或被取消时这一步抛出 `CALL_FAILED`,用出错边界事件接住做自己的收尾。
|
|
37
|
+
|
|
38
|
+
无需会话操作但需要独占设备的流程(如预览版直装、开发流程的设备验收),可自行用 `hold-group device:<id>`(capacity 1)占一段,由引擎到归属实例排队,测试机会话排在它后面;流程负责自己的操作与收尾。
|