@coda-console/testbed 1.1.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/README.md CHANGED
@@ -1,35 +1,38 @@
1
1
  # 测试机
2
2
 
3
- 长插在 Mac 上的 iPhone、iPad 测试机只经这个插件使用。每台设备是一个[并发组](https://github.com/coda-console/coda/blob/main/docs/workflows.md#并发组) `device:<设备名>`,容量 1;
3
+ iPhone、iPad 测试机由这个插件登记归属。每台设备是一个[并发组](https://github.com/coda-console/coda/blob/main/docs/workflows.md#并发组) `device:<设备名>`,容量 1;
4
4
  排到、装 App、交给会话、复原都写在它的能力 `use-testbed`(`.coda/capabilities/use-testbed.bpmn`)里,会话借用设备的命令走它的另一个能力 `run-on-device`。
5
5
  它依赖[宿主资源](../host/README.md)插件(`extensionDependencies`),接在那边的扩展点 `kinds` 上:会话里 `host-sim --kind <设备名>` 由它的 `scripts/host-sim.mjs` 处理。
6
6
  从 Coda 的商店安装(npm 包 `@coda-console/testbed`),装它时一起装上宿主资源;给 Agent 的用法在 Skill `testbed`(`skills/testbed/SKILL.md`)。
7
- 组是协作的锁:项目流程也能写出同名的组,但设备的 UDID、借用和复原只在这个插件里,项目流程调用 `use-testbed`,不自己占设备的组。
7
+ 需要会话操作时调用 `use-testbed`。无需会话操作但需要独占设备的流程(如预览版直装、开发流程的设备验收),可自行用 `hold-group device:<id>`(capacity 1)占一段,测试机会话排在它后面;流程负责自己的操作与收尾。
8
8
 
9
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`)。它是一个数组,每项一台插在这台 Mac 上的设备:
10
+ 另一个[后端实例](https://github.com/coda-console/coda/blob/main/docs/deployment.md#后端实例)是 `P/data/plugins/testbed/devices.json`)。它是一个数组,每项一台归本实例的设备:
11
11
 
12
12
  ```json
13
13
  [{ "id": "iphone-testbed", "name": "iPhone13pm", "udid": "…", "note": "…" }]
14
14
  ```
15
15
 
16
16
  `id` 是设备名(小写字母开头,字母、数字、连字符),`name` 显示名,`udid` 不重复;`note` 可省,是给拿到任务的会话的说明(这台 Mac 上怎么驱动这台设备、哪些东西不能碰),原样附在任务末尾。
17
- 登记属于这台 Mac 的这个实例,不经空间同步。增删改就是改这个文件,每次使用开始时读,不用重启;设备正被使用时不要改名或换 UDID。
17
+ 登记在哪个实例就归哪个实例,`devices.json` 是归属的唯一事实来源。插件启动时声明 `device:<id>`,把 `name`、`udid`、`note` 发布到空间,以 `udid` 为唯一属性;文件变化时重新全量声明。空间里同一设备只能登记一次,同名或相同 UDID 的重复登记会使占用失败并报冲突。
18
+ 登记读不了时日志记录错误,保留已有声明;有效的空数组 `[]` 撤销全部声明。设备正被使用时不要改名或换 UDID。
19
+
20
+ 任何实例的会话都能用设备,由引擎在归属实例排队。安装、会话的 `host-sim` 命令和切黑屏在发起的 Mac 上执行;这台 Mac 须与设备配对,跑 XCTest 还须有本机 runner。
18
21
 
19
22
  **入口**:
20
- - Agent:`workflow_run`,`workflow: "use-testbed"`,`inputs` 给 `device`(设备名,它所在 Mac 登记的 `id`)、
23
+ - Agent:`workflow_run`,`workflow: "use-testbed"`,`inputs` 给 `device`(设备名,归属实例登记的 `id`)、
21
24
  `instructions`(要会话在测试机上做什么、怎么答复),可选 `app`(先装上的 `.app` 或 `.ipa` 的绝对路径)、`task`(任务名,默认「在测试机上验证」)。插件的说明告诉每个会话的 Agent 这样调用。
22
25
  - 其他流程:`<callActivity calledElement="use-testbed">`,参数用 `coda:param`;项目流程先在自己的步骤构建、签名,把 App 路径传给它。
23
26
 
24
27
  **过程**:
25
- 1. 「找测试机」:从本机的设备登记查出这个设备名的显示名、UDID 和说明;没有登记就失败,写明登记了哪些,不排队。
26
- 2. 「等测试机」:`hold-group` 为「占用测试机」这一段排进组 `device:<设备名>`(`capacity` 1、`borrow="no"`:每次使用都排队,做着上一次任务的会话再发起一次也排在后面),排到才往下走;之后到这一段结束都占着它。
28
+ 1. 「找测试机」:先查本地登记;没有这个 id 就用 `coda-resources group-owner device:<id>` 读取归属实例声明的显示名、UDID 和说明。两边都没有则报「没有这台测试机」,列出本地可用设备,不排队。
29
+ 2. 「等测试机」:`hold-group` 为「占用测试机」这一段排进组 `device:<设备名>`(`capacity` 1、`borrow="no"`:每次使用都排队,做着上一次任务的会话再发起一次也排在后面),由引擎到归属实例排队,排到才往下走;之后到这一段结束都占着它。
27
30
  3. 有 `app` 就装上(`xcrun devicectl device install app`),再把任务交给调用的会话:要求是 `instructions`,加上设备名、UDID、借用方法和登记里的说明。
28
31
  做任务期间,这个会话(含子 Agent)用 `host-sim run --kind <设备名> -- <命令>` 操作设备:`host-sim` 经 `coda-run` 把命令交给能力 `run-on-device`,
29
32
  它用 `hold-group borrow="only"` 借这个组——只借给正在做这台设备任务的会话,别的情况立刻拒绝、不排队——借到后在会话的目录里、带会话的环境执行,
30
33
  附上 `HOST_SIM_LEASE=coda-run:<设备名>`(命令里再调用 `host-sim run --kind <设备名>` 直接执行,`host-sim check` 通过),退出码交回 `host-sim`。
31
34
  任务答复、撤回或运行被取消时,还在执行的这些命令随之停下([并发组](https://github.com/coda-console/coda/blob/main/docs/workflows.md#并发组)「借用跟着任务走」),下一步等它们停完才开始。
32
- 4. 「切黑屏」:任务答复、借用的命令停完后,在这台 Mac 上用 `xcrun devicectl device process launch --terminate-existing` 在设备上打开[黑屏 App](#黑屏-app),限时 2 分钟;
35
+ 4. 「切黑屏」:任务答复、借用的命令停完后,在发起的 Mac 上用 `xcrun devicectl device process launch --terminate-existing` 在设备上打开[黑屏 App](#黑屏-app),限时 2 分钟;
33
36
  设置「切黑屏命令」(`testbed.blackscreen`)里有这台设备的一行时,改为用 `sh` 执行那一行,设备的 UDID 在环境变量 `UDID` 里。
34
37
  装 App 或任务出错、任务被拒绝、运行被取消(流程等着 [`workflow.cancel`](https://github.com/coda-console/coda/blob/main/docs/workflows.md#执行与恢复))时,同样先切黑屏;切黑屏时被取消,照样切完。
35
38
  5. 切不过去(命令失败或超时,如设备断开、锁屏或没装黑屏 App):给用户一张卡片「测试机切黑屏失败」,选「重试」或「我处理好了,放锁」;处理之前设备一直占着,不交给下一位。
@@ -38,7 +41,7 @@
38
41
 
39
42
  **结果**:做完了,`result`、`note`、`choice` 是任务的答复,调用者照常读到;没做完时以原来的原因结束(被拒绝时是拒绝的理由),被取消的记为已取消。
40
43
 
41
- **设置**:`testbed.blackscreen`「切黑屏命令」是表格,默认没有行;某台设备要换掉默认的做法时加一行:左边是设备名,右边是在那台 Mac 上执行的命令。
44
+ **设置**:`testbed.blackscreen`「切黑屏命令」是表格,默认没有行;某台设备要换掉默认的做法时加一行:左边是设备名,右边是在发起的 Mac 上执行的命令。
42
45
  设置在整个空间只有一份,所以各台 Mac 的设备写在同一张表里,设备名在空间里不重复。
43
46
 
44
47
  **面板**「测试机」(`devices`):这台 Mac 登记的每台设备一张卡片(显示名、设备名),写谁在用(`use-testbed` 的运行与会话、会话借用的命令)、
@@ -53,4 +56,4 @@ Bundle ID 是 `com.leavan.blackscreen`,切黑屏默认打开的就是它。每
53
56
 
54
57
  1. 在本仓库工作树的会话里,用持锁执行(`run-held`)运行 `sh plugins/testbed/blackscreen/build.sh <输出目录> <描述文件名>`,占一个构建名额;
55
58
  编译后用登录钥匙串里的 Apple Development 证书和这份开发描述文件签名(描述文件须是通配 App ID、包含目标设备,Team 取自描述文件),得到 `<输出目录>/BlackScreen.app`。它不碰设备。
56
- 2. 在设备所在的 Mac 上调用 `use-testbed`,`app` 给这个 `.app`:装上后任务交给会话,答复后的切黑屏打开它,切得过去就是装好了。
59
+ 2. 在与设备配对的 Mac 上调用 `use-testbed`,`app` 给这个 `.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
@@ -2,8 +2,8 @@
2
2
  "name": "@coda-console/testbed",
3
3
  "id": "testbed",
4
4
  "displayName": "测试机",
5
- "description": "长插在 Mac 上的 iPhone、iPad 测试机只经这里使用:排到后按需装上 App,把任务交给会话在真机上操作;会话的 host-sim 命令借用它,任务结束时随之停下;任务结束、出错或取消时先切黑屏再交给下一位,切不过去就请你处理。",
6
- "version": "1.1.0",
5
+ "description": "iPhone、iPad 测试机登记在哪个实例就归哪个实例,空间里同一设备只登记一次,重复占用报冲突。任何实例的会话都能用,在归属实例排队;安装、操作和切黑屏在发起的 Mac 执行,须与设备配对,XCTest 须有本机 runner。无需会话操作的流程可自行 hold-group device:<id>(capacity 1)独占。",
6
+ "version": "1.2.0",
7
7
  "categories": [
8
8
  "机器与资源"
9
9
  ],
@@ -35,7 +35,7 @@
35
35
  "title": "切黑屏命令",
36
36
  "type": "object",
37
37
  "order": 1,
38
- "description": "每台测试机用完、交给下一位之前怎么切黑屏。默认用 devicectl 在设备上打开黑屏 App(com.leavan.blackscreen);某台要换做法就加一行:左边是设备名(它所在 Mac 的测试机插件登记的 id),右边是在那台 Mac 上执行的命令(sh,设备的 UDID 在 $UDID)。失败时请你处理后再交出设备。",
38
+ "description": "每台测试机用完、交给下一位之前怎么切黑屏。默认用 devicectl 在设备上打开黑屏 App(com.leavan.blackscreen);某台要换做法就加一行:左边是设备名(归属实例的测试机插件登记的 id),右边是在发起的 Mac 上执行的命令(sh,设备的 UDID 在 $UDID)。失败时请你处理后再交出设备。",
39
39
  "additionalProperties": {
40
40
  "type": "string"
41
41
  },
@@ -60,6 +60,11 @@
60
60
  ".coda",
61
61
  "scripts",
62
62
  "blackscreen",
63
- "skills"
63
+ "skills",
64
+ "main.mjs"
65
+ ],
66
+ "main": "main.mjs",
67
+ "activationEvents": [
68
+ "onStartupFinished"
64
69
  ]
65
70
  }
@@ -1,17 +1,12 @@
1
- // Which test device a device name is: as this Mac registers it (scripts/registry.mjs, README.md).
2
- // Its display name, UDID and note for the session are the step's outputs `name`, `udid` and `note`; a name not registered
3
- // there fails the step, saying which are.
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
4
  import { appendFileSync } from 'node:fs';
5
- import { devices, file } from './registry.mjs';
5
+ import { resolveDevice } from './resolve-device.mjs';
6
6
 
7
7
  const device = process.env.DEVICE ?? '';
8
- let list;
9
- try { list = devices(); } catch (error) { fail(error.message); }
10
- const found = list.find(d => d?.id === device);
11
- if (!found || typeof found.udid !== 'string' || !found.udid) {
12
- const known = list.map(d => d?.id).filter(Boolean);
13
- fail(`本机没有登记设备 ${device}(${file})${known.length ? `;登记了:${known.join('、')}` : ';一台也没有登记'}`);
14
- }
8
+ let found;
9
+ try { found = resolveDevice(device); } catch (error) { fail(error.message); }
15
10
  console.log(`测试机 ${device}:${found.name ?? device}(${found.udid})`);
16
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`);
17
12
 
@@ -1,22 +1,26 @@
1
- // The test devices plugged into this Mac, as this plugin registers them (README.md): `devices.json`
2
- // in its state (CODA_PLUGIN_STATE, <data>/plugins/testbed/), so each backend environment of the Mac has its own. An array of
3
- // { id, name, udid, note }; no file, no device.
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
4
  import { readFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
 
7
- export const file = join(process.env.CODA_PLUGIN_STATE, 'devices.json');
8
- export function devices() {
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
9
  let list;
10
- try { list = JSON.parse(readFileSync(file, 'utf8')); } catch (error) {
11
- if (error.code === 'ENOENT') return [];
12
- throw new Error(`读不了设备登记 ${file}:${error.message}`);
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`);
13
18
  }
14
- if (!Array.isArray(list)) throw new Error(`设备登记 ${file} 须为设备数组`);
15
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.
16
20
  for (const key of ['id', 'udid']) {
17
21
  const values = list.map(d => d?.[key]).filter(Boolean);
18
22
  const twice = [...new Set(values.filter((value, i) => values.indexOf(value) !== i))];
19
- if (twice.length) throw new Error(`设备登记 ${file} 里 ${key} 重复:${twice.join('、')}`);
23
+ if (twice.length) throw new Error(`设备登记 ${path} 里 ${key} 重复:${twice.join('、')}`);
20
24
  }
21
25
  return list;
22
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
+ }
@@ -5,8 +5,11 @@ description: 测试机插件怎么用:会话或流程里用 use-testbed 排到
5
5
 
6
6
  # 测试机
7
7
 
8
- 测试机(`iphone-testbed`、`ipad-testbed` 这类在它所在 Mac 上登记的设备)只经能力 `use-testbed` 使用:它排进设备的并发组 `device:<设备名>`,
9
- 按需装 App,把任务交给发起的会话;答复、出错或运行被取消后,先等会话借设备还在跑的命令停下,再切黑屏、交出设备。项目流程不自己占设备的组。
8
+ 测试机(`iphone-testbed`、`ipad-testbed` 这类设备)需要会话操作时经能力 `use-testbed` 使用:它排进设备的并发组 `device:<设备名>`,
9
+ 按需装 App,把任务交给发起的会话;答复、出错或运行被取消后,先等会话借设备还在跑的命令停下,再切黑屏、交出设备。
10
+
11
+ 登记在哪个实例就归哪个实例,`devices.json` 是归属的唯一事实来源。空间里同一设备只能登记一次,同名或相同 UDID 的重复登记会使占用失败并报冲突。任何实例的会话都能用,在归属实例排队。
12
+ 安装、会话的 host-sim 命令和切黑屏在发起的 Mac 上跑;这台 Mac 须与设备配对,跑 XCTest 还须有本机 runner。
10
13
 
11
14
  ## 会话里
12
15
 
@@ -31,3 +34,5 @@ description: 测试机插件怎么用:会话或流程里用 use-testbed 排到
31
34
  ```
32
35
 
33
36
  任务的答复是这一步的输出:`=verify.result`、`.note`、`.choice`。被拒绝、出错或被取消时这一步抛出 `CALL_FAILED`,用出错边界事件接住做自己的收尾。
37
+
38
+ 无需会话操作但需要独占设备的流程(如预览版直装、开发流程的设备验收),可自行用 `hold-group device:<id>`(capacity 1)占一段,由引擎到归属实例排队,测试机会话排在它后面;流程负责自己的操作与收尾。