@international-iot-association/plugin-cli 1.0.0-rc.1
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/dist/cli.js +961 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +507 -0
- package/package.json +95 -0
- package/src/check.d.mts +8 -0
- package/src/check.mjs +71 -0
- package/src/manifest-schema.d.mts +9 -0
- package/src/manifest-schema.mjs +224 -0
- package/src/new.ts +220 -0
- package/src/registry.ts +46 -0
- package/src/smoke.d.mts +49 -0
- package/src/smoke.mjs +413 -0
- package/src/stage.ts +86 -0
- package/src/test-runner.d.mts +11 -0
- package/src/test-runner.mjs +82 -0
- package/src/zip.ts +30 -0
package/src/smoke.d.mts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// packages/plugin-cli/src/smoke.mjs 的类型声明(供 cli.ts 等 TS 消费者引入)。
|
|
2
|
+
// 与 manifest-schema.d.mts 同一约定:声明文件与源文件逐字同目录放置。
|
|
3
|
+
export declare const sleep: (ms: number) => Promise<void>;
|
|
4
|
+
|
|
5
|
+
export interface SmokeCounters {
|
|
6
|
+
roundtrips: number;
|
|
7
|
+
reports: number;
|
|
8
|
+
permissionDenials: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export declare function assertSmokeMinimums(
|
|
12
|
+
manifest: Record<string, unknown>,
|
|
13
|
+
counters: SmokeCounters,
|
|
14
|
+
activated: boolean,
|
|
15
|
+
): void;
|
|
16
|
+
|
|
17
|
+
export declare function hasChannelPermission(permissions: unknown, logicalName: string): boolean;
|
|
18
|
+
|
|
19
|
+
export declare function hasChannelControlPermission(
|
|
20
|
+
permissions: unknown,
|
|
21
|
+
logicalName: string,
|
|
22
|
+
): boolean;
|
|
23
|
+
|
|
24
|
+
export declare function hasSerialDiscoveryPermission(permissions: unknown): boolean;
|
|
25
|
+
|
|
26
|
+
export declare function makeMemoryStorage(): unknown;
|
|
27
|
+
|
|
28
|
+
export declare function makeBusRouter(): unknown;
|
|
29
|
+
|
|
30
|
+
export declare function makeBusConsumer(
|
|
31
|
+
router: unknown,
|
|
32
|
+
consumerId: string,
|
|
33
|
+
providerId: string,
|
|
34
|
+
counters: SmokeCounters,
|
|
35
|
+
): unknown;
|
|
36
|
+
|
|
37
|
+
export declare function makeGatedHarness(
|
|
38
|
+
manifest: Record<string, unknown>,
|
|
39
|
+
opts?: Record<string, unknown>,
|
|
40
|
+
counters?: SmokeCounters,
|
|
41
|
+
): unknown;
|
|
42
|
+
|
|
43
|
+
export interface SmokeResult {
|
|
44
|
+
pluginId: string;
|
|
45
|
+
roundtrips: number;
|
|
46
|
+
reports: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export declare function runPluginSmoke(pluginDir: string): Promise<SmokeResult>;
|
package/src/smoke.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// packages/plugin-cli/src/smoke.mjs —— 插件无头 smoke 的共享工具箱。
|
|
2
|
+
// 迁移自仓内 electron-station-plugins/tools/smoke-harness.mjs(ADR-0032 §3.5-N2,
|
|
3
|
+
// WP-3b)。逻辑与原文件逐字保持一致,唯一变化:api-bridge 从仓内相对路径
|
|
4
|
+
// import 改为包依赖 @international-iot-association/api-bridge(发布时按 scope 改写)。
|
|
5
|
+
// tools/smoke-harness.mjs 现为薄包装:export * from "@international-iot-association/plugin-cli/smoke"。
|
|
6
|
+
//
|
|
7
|
+
// 与外壳等价的行为:
|
|
8
|
+
// - ctx 形状对齐 electron-station-shell/src/main/plugin-host/runner.ts;
|
|
9
|
+
// - 通道权限门对齐 electron-station-shell/src/main/plugin/pluginManager.ts:
|
|
10
|
+
// writeRead/subscribe: <name> | <name>.writeRead | channel.<name>.writeRead
|
|
11
|
+
// control(configure/open/close/status): <name>.control | channel.<name>.control
|
|
12
|
+
// listPorts/detectDevices: serial | serial.discovery | channel.serial.discovery(.read)
|
|
13
|
+
// storage: 仅声明 "storage" 权限时注入
|
|
14
|
+
// dialog: 仅声明 "ui:file-dialog" 权限时注入
|
|
15
|
+
// platform.upload: 仅声明 "platform:object-upload" 权限时注入
|
|
16
|
+
// platform.virtualSerial: 仅声明 "devtools:virtual-serial" 权限时注入
|
|
17
|
+
// (权限决定方法在不在,外壳设置开关决定调用成不成——与关系)
|
|
18
|
+
// writeReadMock: 不需要权限(进程内合成)
|
|
19
|
+
// - 权限不足时抛出与外壳同语义的错误 —— smoke 场景不能用 ctx 桩绕过权限门。
|
|
20
|
+
//
|
|
21
|
+
// 每个插件在其根目录提供 smoke.mjs:
|
|
22
|
+
// export default async function smoke(toolkit) { ... }
|
|
23
|
+
// 由 tools/verify.mjs 发现并执行。toolkit 的 runSmoke 完成后强制断言:
|
|
24
|
+
// activate 成功 + 至少一次 API 往返 +(station 插件)至少一条 ctx.report.result。
|
|
25
|
+
// 已退役的 rti.global.logger 兼容壳不提供 API,仅在 manifest 仍满足退役不变量时豁免往返。
|
|
26
|
+
|
|
27
|
+
import assert from "node:assert/strict";
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { pathToFileURL } from "node:url";
|
|
31
|
+
import { createAPIClient } from "@international-iot-association/api-bridge";
|
|
32
|
+
import { validatePluginManifest } from "./manifest-schema.mjs";
|
|
33
|
+
|
|
34
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
|
|
36
|
+
function isRetiredGlobalLoggerCompatibilityShell(manifest) {
|
|
37
|
+
return (
|
|
38
|
+
manifest.id === "rti.global.logger" &&
|
|
39
|
+
manifest.kind === "global" &&
|
|
40
|
+
manifest.autoStart === false &&
|
|
41
|
+
Array.isArray(manifest.permissions) &&
|
|
42
|
+
manifest.permissions.length === 0
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertSmokeMinimums(manifest, counters, activated) {
|
|
47
|
+
assert.ok(activated, `${manifest.id} smoke 未调用 toolkit.activate()`);
|
|
48
|
+
assert.ok(
|
|
49
|
+
counters.roundtrips >= 1 || isRetiredGlobalLoggerCompatibilityShell(manifest),
|
|
50
|
+
`${manifest.id} smoke 未完成任何 API 往返`,
|
|
51
|
+
);
|
|
52
|
+
const kind = manifest.kind ?? "station";
|
|
53
|
+
if (kind === "station") {
|
|
54
|
+
assert.ok(counters.reports >= 1, `${manifest.id} smoke 未产生任何 ctx.report.result`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- 权限判定(与 pluginManager.ts 保持逐字等价的规则)----------------------
|
|
59
|
+
|
|
60
|
+
export function hasChannelPermission(permissions, logicalName) {
|
|
61
|
+
return (
|
|
62
|
+
permissions.includes(logicalName) ||
|
|
63
|
+
permissions.includes(`${logicalName}.writeRead`) ||
|
|
64
|
+
permissions.includes(`channel.${logicalName}.writeRead`)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hasChannelControlPermission(permissions, logicalName) {
|
|
69
|
+
return (
|
|
70
|
+
permissions.includes(`${logicalName}.control`) ||
|
|
71
|
+
permissions.includes(`channel.${logicalName}.control`)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function hasSerialDiscoveryPermission(permissions) {
|
|
76
|
+
return permissions.some(
|
|
77
|
+
(perm) =>
|
|
78
|
+
perm === "serial" ||
|
|
79
|
+
perm === "serial.discovery" ||
|
|
80
|
+
perm === "channel.serial.discovery" ||
|
|
81
|
+
perm === "channel.serial.discovery.read",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- 进程内桩(与 verify-bridge.mjs 同源语义)-------------------------------
|
|
86
|
+
|
|
87
|
+
/** 内存版 ctx.storage:与外壳 runner 的语义一致(read 缺省 null、list 仅文件名)。 */
|
|
88
|
+
export function makeMemoryStorage() {
|
|
89
|
+
const files = new Map();
|
|
90
|
+
const normalize = (p) => p.replace(/^\.\//, "");
|
|
91
|
+
return {
|
|
92
|
+
dir: "/mem",
|
|
93
|
+
async read(p) {
|
|
94
|
+
const key = normalize(p);
|
|
95
|
+
return files.has(key) ? files.get(key) : null;
|
|
96
|
+
},
|
|
97
|
+
async write(p, c) {
|
|
98
|
+
files.set(normalize(p), c);
|
|
99
|
+
},
|
|
100
|
+
async append(p, c) {
|
|
101
|
+
const key = normalize(p);
|
|
102
|
+
files.set(key, (files.get(key) ?? "") + c);
|
|
103
|
+
},
|
|
104
|
+
async list(dir = ".") {
|
|
105
|
+
const prefix = dir === "." ? "" : `${dir.replace(/\/+$/, "")}/`;
|
|
106
|
+
const names = [];
|
|
107
|
+
for (const key of files.keys()) {
|
|
108
|
+
if (!key.startsWith(prefix)) continue;
|
|
109
|
+
const rest = key.slice(prefix.length);
|
|
110
|
+
if (rest && !rest.includes("/")) names.push(rest);
|
|
111
|
+
}
|
|
112
|
+
return names;
|
|
113
|
+
},
|
|
114
|
+
async remove(p) {
|
|
115
|
+
files.delete(normalize(p));
|
|
116
|
+
},
|
|
117
|
+
_files: files,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 进程内服务总线路由:模拟外壳的 from/to 转发(目标未注册则丢弃)。 */
|
|
122
|
+
export function makeBusRouter() {
|
|
123
|
+
const nodes = new Map();
|
|
124
|
+
return {
|
|
125
|
+
register(pluginId) {
|
|
126
|
+
const handlers = [];
|
|
127
|
+
nodes.set(pluginId, handlers);
|
|
128
|
+
return {
|
|
129
|
+
postMessage(to, envelope) {
|
|
130
|
+
const target = nodes.get(to);
|
|
131
|
+
if (!target) return;
|
|
132
|
+
queueMicrotask(() => {
|
|
133
|
+
for (const h of target) h(pluginId, envelope);
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
onMessage(handler) {
|
|
137
|
+
handlers.push(handler);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 以「消费者插件」身份接到总线上,返回原始 api-bridge 客户端(轴 D)。 */
|
|
145
|
+
export function makeBusConsumer(router, consumerId, providerId, counters) {
|
|
146
|
+
const endpoint = router.register(consumerId);
|
|
147
|
+
const { client, clientMessageHandler, serverEventHandler } = createAPIClient({
|
|
148
|
+
requestServerFunc: async (req) => {
|
|
149
|
+
endpoint.postMessage(providerId, req);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
endpoint.onMessage((_from, envelope) => {
|
|
153
|
+
if (envelope.type === "response") {
|
|
154
|
+
counters?.roundtrips !== undefined && (counters.roundtrips += 1);
|
|
155
|
+
clientMessageHandler(envelope);
|
|
156
|
+
} else if (envelope.type === "event") {
|
|
157
|
+
serverEventHandler(envelope);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return client;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- 权限门 ctx + 轴 A 客户端 ------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 构造一个带权限门的无头 ctx 与轴 A 客户端。
|
|
167
|
+
* @param manifest 已校验的插件 manifest(权限门数据源)
|
|
168
|
+
* @param opts.writeRead 设备往返桩(缺省 fail-closed:"no device configured")
|
|
169
|
+
* @param opts.controlOverride 覆盖 control 行为(缺省 mock provider 状态机应答)
|
|
170
|
+
* @param opts.bus 轴 D 端点(缺省断线总线)
|
|
171
|
+
* @param opts.storage 内存 storage(仅当 manifest 声明 storage 权限时注入)
|
|
172
|
+
* @param opts.dialog dialog 桩(仅当 manifest 声明 ui:file-dialog 时注入)
|
|
173
|
+
* @param opts.upload 机台上传桩(仅当 manifest 声明 platform:object-upload 时注入)
|
|
174
|
+
* @param opts.virtualSerial 虚拟串口桩(仅当 manifest 声明 devtools:virtual-serial 时注入;
|
|
175
|
+
* 缺省桩三个方法全部抛错,对齐外壳「开关默认关闭」的语义)
|
|
176
|
+
* @param counters 由 runSmoke 传入的计数器
|
|
177
|
+
*/
|
|
178
|
+
export function makeGatedHarness(manifest, opts = {}, counters = { roundtrips: 0 }) {
|
|
179
|
+
const permissions = manifest.permissions ?? [];
|
|
180
|
+
const events = [];
|
|
181
|
+
const results = [];
|
|
182
|
+
const subscriptions = new Map();
|
|
183
|
+
let runtimeOnMessage = null;
|
|
184
|
+
|
|
185
|
+
const deny = (message) => {
|
|
186
|
+
const err = new Error(message);
|
|
187
|
+
counters.permissionDenials = (counters.permissionDenials ?? 0) + 1;
|
|
188
|
+
throw err;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const defaultControl = async (request) => {
|
|
192
|
+
if (request.action === "configure") {
|
|
193
|
+
return {
|
|
194
|
+
logicalName: request.config.logicalName,
|
|
195
|
+
provider: request.config.provider,
|
|
196
|
+
state: "closed",
|
|
197
|
+
metrics: {},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (request.action === "open") {
|
|
201
|
+
return { logicalName: request.logicalName, provider: "mock", state: "open", metrics: {} };
|
|
202
|
+
}
|
|
203
|
+
if (request.action === "close") {
|
|
204
|
+
return { logicalName: request.logicalName, provider: "mock", state: "closed", metrics: {} };
|
|
205
|
+
}
|
|
206
|
+
if (request.action === "status") {
|
|
207
|
+
return { logicalName: request.logicalName, provider: "mock", state: "open", metrics: {} };
|
|
208
|
+
}
|
|
209
|
+
if (request.action === "listPorts" || request.action === "detectDevices") return [];
|
|
210
|
+
throw new Error(`unexpected control action: ${request.action}`);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const ctx = {
|
|
214
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
215
|
+
channels: {
|
|
216
|
+
async writeReadMock(logicalName, payload) {
|
|
217
|
+
return {
|
|
218
|
+
logicalName,
|
|
219
|
+
request: payload,
|
|
220
|
+
response: `MOCK<${logicalName}>:${payload}:OK`,
|
|
221
|
+
latencyMs: 1,
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
async writeRead(logicalName, payload, options) {
|
|
225
|
+
if (!hasChannelPermission(permissions, logicalName)) {
|
|
226
|
+
deny(
|
|
227
|
+
`permission denied: plugin "${manifest.id}" has no permission for channel "${logicalName}"`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (!opts.writeRead) throw new Error("no device configured");
|
|
231
|
+
return opts.writeRead(logicalName, payload, options);
|
|
232
|
+
},
|
|
233
|
+
async control(request) {
|
|
234
|
+
if (request.action === "listPorts" || request.action === "detectDevices") {
|
|
235
|
+
if (!hasSerialDiscoveryPermission(permissions)) {
|
|
236
|
+
deny(`plugin "${manifest.id}" has no serial discovery permission for ${request.action}`);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
const logicalName =
|
|
240
|
+
request.action === "configure" ? request.config.logicalName : request.logicalName;
|
|
241
|
+
if (!hasChannelControlPermission(permissions, logicalName)) {
|
|
242
|
+
deny(`plugin ${manifest.id} does not have permission for channel ${logicalName}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return (opts.controlOverride ?? defaultControl)(request);
|
|
246
|
+
},
|
|
247
|
+
subscribe(logicalName, handler) {
|
|
248
|
+
if (!hasChannelPermission(permissions, logicalName)) {
|
|
249
|
+
deny(
|
|
250
|
+
`permission denied: plugin "${manifest.id}" has no permission for channel "${logicalName}"`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const list = subscriptions.get(logicalName) ?? [];
|
|
254
|
+
list.push(handler);
|
|
255
|
+
subscriptions.set(logicalName, list);
|
|
256
|
+
// 与契约一致:返回反注册函数(移除本次 handler,幂等)。
|
|
257
|
+
return () => {
|
|
258
|
+
const current = subscriptions.get(logicalName);
|
|
259
|
+
if (!current) return;
|
|
260
|
+
const idx = current.indexOf(handler);
|
|
261
|
+
if (idx >= 0) current.splice(idx, 1);
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
report: {
|
|
266
|
+
stepEvent: (e) => events.push(e),
|
|
267
|
+
result: (r) => {
|
|
268
|
+
counters.reports = (counters.reports ?? 0) + 1;
|
|
269
|
+
results.push(r);
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
ui: {
|
|
273
|
+
postMessage: (env) => {
|
|
274
|
+
if (env.type === "response") {
|
|
275
|
+
counters.roundtrips += 1;
|
|
276
|
+
clientMessageHandler(env);
|
|
277
|
+
} else if (env.type === "event") {
|
|
278
|
+
serverEventHandler(env);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
onMessage: (h) => {
|
|
282
|
+
runtimeOnMessage = h;
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
bus: opts.bus ?? { postMessage() {}, onMessage() {} },
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (permissions.includes("storage")) {
|
|
289
|
+
ctx.storage = opts.storage ?? makeMemoryStorage();
|
|
290
|
+
}
|
|
291
|
+
if (permissions.includes("ui:file-dialog")) {
|
|
292
|
+
ctx.dialog = opts.dialog ?? {
|
|
293
|
+
async openFile() {
|
|
294
|
+
// W1-7 契约:files 为一次性 handle 列表(不再回传绝对路径 filePaths)。
|
|
295
|
+
return { canceled: true, files: [] };
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (permissions.includes("platform:object-upload")) {
|
|
300
|
+
// 机台上传对象。缺省桩**恒失败且不可重试**:smoke 里"什么都不接就默默成功"
|
|
301
|
+
// 会让一个根本没接线的上传路径看起来是绿的。要测成功路径就自己传 opts.upload。
|
|
302
|
+
ctx.platform = {
|
|
303
|
+
...(ctx.platform ?? {}),
|
|
304
|
+
upload: opts.upload ?? {
|
|
305
|
+
async submit() {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
code: "internal",
|
|
309
|
+
faultCode: "UP-SMOKE-STUB",
|
|
310
|
+
retryable: false,
|
|
311
|
+
detail: "smoke 缺省上传桩:未注入 opts.upload",
|
|
312
|
+
attempts: 1,
|
|
313
|
+
uploadedBeforeFailure: 0,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (permissions.includes("devtools:virtual-serial")) {
|
|
321
|
+
// 虚拟串口(ADR-0031 D2/D3)。与外壳同款「与关系」:**权限决定方法在不在,
|
|
322
|
+
// 开关决定调用成不成**——外壳里开关默认关闭,create 会抛「未启用」。
|
|
323
|
+
// 所以缺省桩也恒失败:smoke 里默默铸出一对假 PTY 会让「没开开关也能跑」
|
|
324
|
+
// 这个错误结论看起来是绿的。要测成功路径就自己传 opts.virtualSerial。
|
|
325
|
+
//
|
|
326
|
+
// 注意这里**没有**、也不许有任何把 PTY 绑进通道的方法(ADR-0031 D6):
|
|
327
|
+
// clientPath 只作只读文本交出去,由人工填进工位配置。
|
|
328
|
+
const denyVirtualSerial = async () => {
|
|
329
|
+
throw new Error(
|
|
330
|
+
"虚拟串口能力未启用:smoke 缺省桩不铸造 PTY(要测成功路径请注入 opts.virtualSerial)",
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
ctx.platform = {
|
|
334
|
+
...(ctx.platform ?? {}),
|
|
335
|
+
virtualSerial: opts.virtualSerial ?? {
|
|
336
|
+
create: denyVirtualSerial,
|
|
337
|
+
release: denyVirtualSerial,
|
|
338
|
+
list: denyVirtualSerial,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const { client, clientMessageHandler, serverEventHandler } = createAPIClient({
|
|
344
|
+
requestServerFunc: async (req) => {
|
|
345
|
+
runtimeOnMessage?.(req);
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/** 向订阅了 logicalName 的 runtime 处理器注入一条设备事件。 */
|
|
350
|
+
const emitChannelEvent = (logicalName, event) => {
|
|
351
|
+
for (const handler of subscriptions.get(logicalName) ?? []) handler(event);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
return { ctx, client, events, results, subscriptions, emitChannelEvent, storage: ctx.storage };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- smoke 执行入口 -----------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 运行一个插件的 smoke.mjs。
|
|
361
|
+
* @param pluginDir 插件绝对路径(plugins/<dir>)
|
|
362
|
+
* @returns {Promise<{ pluginId: string, roundtrips: number, reports: number }>}
|
|
363
|
+
*/
|
|
364
|
+
export async function runPluginSmoke(pluginDir) {
|
|
365
|
+
const manifestRaw = readFileSync(join(pluginDir, "manifest.json"), "utf8");
|
|
366
|
+
const manifest = JSON.parse(manifestRaw);
|
|
367
|
+
const { ok, errors } = validatePluginManifest(manifest);
|
|
368
|
+
if (!ok) throw new Error(`manifest 校验失败(${pluginDir}):${errors.join("; ")}`);
|
|
369
|
+
|
|
370
|
+
const counters = { roundtrips: 0, reports: 0, permissionDenials: 0 };
|
|
371
|
+
let activated = false;
|
|
372
|
+
|
|
373
|
+
const toolkit = {
|
|
374
|
+
assert,
|
|
375
|
+
sleep,
|
|
376
|
+
manifest,
|
|
377
|
+
pluginDir,
|
|
378
|
+
counters,
|
|
379
|
+
makeBusRouter,
|
|
380
|
+
makeMemoryStorage,
|
|
381
|
+
makeBusConsumer: (router, consumerId, providerId) =>
|
|
382
|
+
makeBusConsumer(router, consumerId, providerId, counters),
|
|
383
|
+
createHarness: (opts = {}) => makeGatedHarness(manifest, opts, counters),
|
|
384
|
+
/**
|
|
385
|
+
* 激活已构建 runtime 并登记 smoke 最小契约。
|
|
386
|
+
* override 只供需要测试专用 assembly 的 smoke 使用;默认仍加载插件导出的生产 activate。
|
|
387
|
+
*/
|
|
388
|
+
async activate(ctx, override) {
|
|
389
|
+
let runtimeActivate = override;
|
|
390
|
+
if (runtimeActivate === undefined) {
|
|
391
|
+
const entry = pathToFileURL(join(pluginDir, "runtime-dist", "main.mjs")).href;
|
|
392
|
+
const mod = await import(entry);
|
|
393
|
+
runtimeActivate = mod.activate;
|
|
394
|
+
}
|
|
395
|
+
if (typeof runtimeActivate !== "function") {
|
|
396
|
+
throw new Error(`runtime-dist/main.mjs 未提供可调用的 activate()(${manifest.id})`);
|
|
397
|
+
}
|
|
398
|
+
const handle = await runtimeActivate(ctx);
|
|
399
|
+
activated = true;
|
|
400
|
+
return handle;
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const smokeUrl = pathToFileURL(join(pluginDir, "smoke.mjs")).href;
|
|
405
|
+
const smokeMod = await import(smokeUrl);
|
|
406
|
+
if (typeof smokeMod.default !== "function") {
|
|
407
|
+
throw new Error(`smoke.mjs 必须默认导出 async 函数(${manifest.id})`);
|
|
408
|
+
}
|
|
409
|
+
await smokeMod.default(toolkit);
|
|
410
|
+
|
|
411
|
+
assertSmokeMinimums(manifest, counters, activated);
|
|
412
|
+
return { pluginId: manifest.id, roundtrips: counters.roundtrips, reports: counters.reports };
|
|
413
|
+
}
|
package/src/stage.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 单插件目录的 staging 纯函数:从 electron-station-plugins/tools/pack.ts:131-165 抽出。
|
|
2
|
+
//
|
|
3
|
+
// 与仓内原实现的关键差异(WP-3a 决定):
|
|
4
|
+
// - 只接受调用方传入的 pluginDir / stagingDir,不做全仓插件遍历;
|
|
5
|
+
// - 不 rmSync 任何全仓目录(原实现 :211 会清空 repoRoot/dist,这里绝不触碰);
|
|
6
|
+
// - 签名相关逻辑(sign.mjs)一个符号都不 import,留在仓内 tools/pack.ts。
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { assertPluginUiSingleReact } from "@international-iot-association/plugin-vite-config";
|
|
10
|
+
import { validatePluginManifest } from "./manifest-schema.mjs";
|
|
11
|
+
|
|
12
|
+
/** 插件 manifest.json 的形状,与仓内 tools/pack.ts 的 Manifest 接口一致。 */
|
|
13
|
+
export interface PluginManifest {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
kind?: "station" | "global";
|
|
19
|
+
stationTypes: string[];
|
|
20
|
+
/** 可选展示元数据;写进 registry 片段时归一化为 []。 */
|
|
21
|
+
models?: string[];
|
|
22
|
+
agentApi: string;
|
|
23
|
+
permissions: string[];
|
|
24
|
+
autoStart?: boolean;
|
|
25
|
+
dependencies?: Record<string, string>;
|
|
26
|
+
/** kind=global 时可省略(无界面后台插件,不打包 ui/)。 */
|
|
27
|
+
uiEntry?: string;
|
|
28
|
+
runtimeEntry: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertExists(path: string, label: string): void {
|
|
32
|
+
if (!existsSync(path)) throw new Error(`${label} not found: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 读取并严格校验单个插件目录的 manifest.json(fail-closed,校验失败即抛错)。 */
|
|
36
|
+
export function readPluginManifest(pluginDir: string): PluginManifest {
|
|
37
|
+
const manifestPath = join(pluginDir, "manifest.json");
|
|
38
|
+
if (!existsSync(manifestPath)) {
|
|
39
|
+
throw new Error(`missing manifest.json in ${pluginDir}`);
|
|
40
|
+
}
|
|
41
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as PluginManifest;
|
|
42
|
+
const { ok, errors } = validatePluginManifest(manifest);
|
|
43
|
+
if (!ok) {
|
|
44
|
+
throw new Error(`manifest 校验失败(${manifestPath}):\n - ${errors.join("\n - ")}`);
|
|
45
|
+
}
|
|
46
|
+
return manifest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把单个插件目录的构建产物组装进 stagingDir:
|
|
51
|
+
* manifest.json -> 根;schemas/ -> schemas/;ui-dist/ -> ui/;runtime-dist/ -> runtime/。
|
|
52
|
+
* 同时做入口存在性断言(uiEntry/runtimeEntry 在 staging 内必须能解析到)与产物单 React 断言。
|
|
53
|
+
* 只清理并写入调用方传入的 stagingDir,不触碰任何全仓目录。
|
|
54
|
+
*/
|
|
55
|
+
export function stagePlugin(pluginDir: string, stagingDir: string): PluginManifest {
|
|
56
|
+
const manifest = readPluginManifest(pluginDir);
|
|
57
|
+
|
|
58
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
59
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
cpSync(join(pluginDir, "manifest.json"), join(stagingDir, "manifest.json"));
|
|
62
|
+
|
|
63
|
+
const schemas = join(pluginDir, "schemas");
|
|
64
|
+
if (existsSync(schemas)) cpSync(schemas, join(stagingDir, "schemas"), { recursive: true });
|
|
65
|
+
|
|
66
|
+
if (manifest.uiEntry) {
|
|
67
|
+
const uiDist = join(pluginDir, "ui-dist");
|
|
68
|
+
assertExists(uiDist, `ui build output (run "pnpm --filter ${manifest.id} build")`);
|
|
69
|
+
// 拒收双 React 单文件产物(useRef null / 空白页)
|
|
70
|
+
const reactGate = assertPluginUiSingleReact(pluginDir);
|
|
71
|
+
if (!reactGate.ok) {
|
|
72
|
+
throw new Error(reactGate.error);
|
|
73
|
+
}
|
|
74
|
+
cpSync(uiDist, join(stagingDir, "ui"), { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const runtimeDist = join(pluginDir, "runtime-dist");
|
|
78
|
+
assertExists(runtimeDist, `runtime build output (run "pnpm --filter ${manifest.id} build")`);
|
|
79
|
+
cpSync(runtimeDist, join(stagingDir, "runtime"), { recursive: true });
|
|
80
|
+
|
|
81
|
+
// 快速失败:installer 之后要校验的入口必须能在 staging 内解析到。
|
|
82
|
+
if (manifest.uiEntry) assertExists(join(stagingDir, manifest.uiEntry), `uiEntry "${manifest.uiEntry}"`);
|
|
83
|
+
assertExists(join(stagingDir, manifest.runtimeEntry), `runtimeEntry "${manifest.runtimeEntry}"`);
|
|
84
|
+
|
|
85
|
+
return manifest;
|
|
86
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// test-runner.mjs 的类型声明。
|
|
2
|
+
export interface TestRunResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
testCount: number | null;
|
|
5
|
+
output: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export declare function parseTapTestCount(output: string): number | null;
|
|
10
|
+
export declare function parseTapFailureCount(output: string): number | null;
|
|
11
|
+
export declare function runPluginTestSuite(pluginDir: string): TestRunResult;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 单插件测试执行 + TAP 汇总解析(ADR-0032 §3.5-N2)。
|
|
2
|
+
// 与仓内 tools/verify.mjs 的 parseNodeTestSummary 同一套判据:显式强制
|
|
3
|
+
// --test-reporter=tap,解析不到 "# tests N" 一律判失败——Node 22+ 默认 spec
|
|
4
|
+
// reporter,不钉死会把没有真正跑测试的插件误判成通过(假绿)。
|
|
5
|
+
// 同样必须解析 "# fail N" / "# cancelled N":仅看 "# tests N" + 进程退出码会漏判
|
|
6
|
+
// 「测试脚本吞掉了非零退出码,但 TAP 里确有失败/取消用例」这一假绿场景
|
|
7
|
+
// (WP-3c 评审修复,避免 verify.mjs 第 5 步比旧 parseNodeTestSummary 更松)。
|
|
8
|
+
//
|
|
9
|
+
// 本文件是纯 JS(非 .ts):需要能被 tools/verify.mjs 用 `node`(非 tsx)直接静态
|
|
10
|
+
// 导入,不依赖 Node 版本的 TS type-stripping 能力,与 check.mjs 同一整改(WP-3c
|
|
11
|
+
// 评审修复)。类型声明见同目录 test-runner.d.mts。
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
/** 解析 `node --test` 的 TAP 汇总;找不到返回 null(与 verify.mjs 判据一致)。 */
|
|
15
|
+
export function parseTapTestCount(output) {
|
|
16
|
+
const matches = [...output.matchAll(/^# tests (\d+)$/gm)];
|
|
17
|
+
const last = matches.at(-1);
|
|
18
|
+
return last ? Number(last[1]) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 解析 TAP 汇总里的失败数(`# fail N` + `# cancelled N` 之和,取最后一份汇总)。
|
|
23
|
+
* 找不到 `# fail` 行返回 null——与 verify.mjs 的 parseNodeTestSummary 同一判据:
|
|
24
|
+
* 没有 fail 行时视为汇总不完整,交给调用方按「没有真正跑测试」处理。
|
|
25
|
+
*/
|
|
26
|
+
export function parseTapFailureCount(output) {
|
|
27
|
+
const fail = [...output.matchAll(/^# fail (\d+)$/gm)].at(-1);
|
|
28
|
+
if (!fail) return null;
|
|
29
|
+
const cancelled = [...output.matchAll(/^# cancelled (\d+)$/gm)].at(-1);
|
|
30
|
+
return Number(fail[1]) + (cancelled ? Number(cancelled[1]) : 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 在插件目录跑 `pnpm test`,通过 NODE_OPTIONS 强制 TAP reporter;
|
|
35
|
+
* 解析不到汇总、汇总为 0、有失败/取消用例、或进程非零退出,一律判失败。
|
|
36
|
+
* 供 CLI `check` / `smoke` 子命令复用,避免两处各写一份判据。
|
|
37
|
+
*/
|
|
38
|
+
export function runPluginTestSuite(pluginDir) {
|
|
39
|
+
const result = spawnSync("pnpm", ["test"], {
|
|
40
|
+
cwd: pluginDir,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
// Windows 上 pnpm 是 pnpm.cmd,不带 shell 直接 spawn 会 ENOENT;
|
|
43
|
+
// 与仓内 tools/verify.mjs 的既定写法(run()/runCaptured())保持一致。
|
|
44
|
+
shell: process.platform === "win32",
|
|
45
|
+
env: {
|
|
46
|
+
...process.env,
|
|
47
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --test-reporter=tap`.trim(),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (result.error) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
testCount: null,
|
|
54
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
55
|
+
error: `无法启动测试进程:${result.error.message}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
59
|
+
const testCount = parseTapTestCount(output);
|
|
60
|
+
if (testCount === null) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
testCount: null,
|
|
64
|
+
output,
|
|
65
|
+
error: "测试输出中没有 node --test TAP 汇总(脚本没有真正跑测试,或未使用 node --test)",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (testCount === 0) {
|
|
69
|
+
return { ok: false, testCount, output, error: "0 个测试用例" };
|
|
70
|
+
}
|
|
71
|
+
// 必须先查 fail/cancelled 再查进程退出码:测试脚本可能吞掉非零退出码
|
|
72
|
+
// (例如 `node --test ...; echo done`),此时 result.status 会是 0,
|
|
73
|
+
// 只有 TAP 汇总里的 "# fail N" / "# cancelled N" 能揭穿假绿。
|
|
74
|
+
const failCount = parseTapFailureCount(output);
|
|
75
|
+
if (failCount !== null && failCount > 0) {
|
|
76
|
+
return { ok: false, testCount, output, error: `TAP 汇总有 ${failCount} 个失败/取消用例` };
|
|
77
|
+
}
|
|
78
|
+
if (result.status !== 0) {
|
|
79
|
+
return { ok: false, testCount, output, error: `测试进程退出码 ${result.status}` };
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, testCount, output };
|
|
82
|
+
}
|
package/src/zip.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 把 staging 目录打成 zip 并计算 sha256。与 pack.ts:223-229 逻辑等价,
|
|
2
|
+
// 但只对调用方给定的 stagingDir / outZip 生效,不做任何全仓路径假设。
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import AdmZip from "adm-zip";
|
|
6
|
+
|
|
7
|
+
export interface ZipResult {
|
|
8
|
+
zipPath: string;
|
|
9
|
+
sha256: string;
|
|
10
|
+
sizeBytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 把 stagingDir 的内容打到 zip 根目录(过滤 .DS_Store),写到 outZip,
|
|
15
|
+
* 返回 zip 路径、sha256、字节数。
|
|
16
|
+
*
|
|
17
|
+
* 注意:AdmZip.addLocalFolder 的条目带 mtime,且调用方通常会在 registry 片段里
|
|
18
|
+
* 写入 generatedAt,所以同一份插件连打两次 sha256 必不同——这是预期行为,
|
|
19
|
+
* 不是本函数的 bug(等价性验收走「解包后逐文件 sha256」,不是「同 sha256」)。
|
|
20
|
+
*/
|
|
21
|
+
export function zipStaging(stagingDir: string, outZip: string): ZipResult {
|
|
22
|
+
const zip = new AdmZip();
|
|
23
|
+
zip.addLocalFolder(stagingDir, "", (entryName) => !entryName.includes(".DS_Store"));
|
|
24
|
+
zip.writeZip(outZip);
|
|
25
|
+
|
|
26
|
+
const bytes = readFileSync(outZip);
|
|
27
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
28
|
+
const sizeBytes = statSync(outZip).size;
|
|
29
|
+
return { zipPath: outZip, sha256, sizeBytes };
|
|
30
|
+
}
|