@burmese/plugin-sdk 3.1.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/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/check-plugin-sdk.d.ts +2 -0
- package/dist/check-plugin-sdk.d.ts.map +1 -0
- package/dist/check-plugin-sdk.js +120 -0
- package/dist/check-plugin-sdk.js.map +1 -0
- package/dist/index.d.ts +858 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +246 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +645 -0
- package/dist/testing.js.map +1 -0
- package/package.json +46 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
const allowedReactions = new Set(["idle", "thinking", "working", "editing", "running", "testing", "waiting", "waving", "success", "error", "celebrating"]);
|
|
2
|
+
function assertReaction(value) {
|
|
3
|
+
if (typeof value !== "string" || !allowedReactions.has(value))
|
|
4
|
+
throw new Error("Invalid pet reaction.");
|
|
5
|
+
}
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fake clock
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export class FakeClock {
|
|
10
|
+
#nowMs;
|
|
11
|
+
#schedules;
|
|
12
|
+
#onError;
|
|
13
|
+
constructor(nowMs, schedules, onError) {
|
|
14
|
+
this.#nowMs = nowMs;
|
|
15
|
+
this.#schedules = schedules;
|
|
16
|
+
this.#onError = onError;
|
|
17
|
+
}
|
|
18
|
+
now() {
|
|
19
|
+
return this.#nowMs;
|
|
20
|
+
}
|
|
21
|
+
/** Advance virtual time, firing due schedules in order. Accepts ms or "30s"/"90m"/"2h"/"1d". */
|
|
22
|
+
async advance(amount) {
|
|
23
|
+
const target = this.#nowMs + parseDuration(amount);
|
|
24
|
+
for (;;) {
|
|
25
|
+
let nextId;
|
|
26
|
+
let nextDue = Number.POSITIVE_INFINITY;
|
|
27
|
+
for (const [id, schedule] of this.#schedules) {
|
|
28
|
+
if (schedule.dueMs <= target && schedule.dueMs < nextDue) {
|
|
29
|
+
nextDue = schedule.dueMs;
|
|
30
|
+
nextId = id;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (nextId === undefined)
|
|
34
|
+
break;
|
|
35
|
+
const schedule = this.#schedules.get(nextId);
|
|
36
|
+
this.#nowMs = Math.max(this.#nowMs, schedule.dueMs);
|
|
37
|
+
if (schedule.type === "once" || schedule.type === "at")
|
|
38
|
+
this.#schedules.delete(nextId);
|
|
39
|
+
else if (schedule.type === "every")
|
|
40
|
+
schedule.dueMs += schedule.intervalMs ?? 60_000;
|
|
41
|
+
else if (schedule.type === "daily")
|
|
42
|
+
schedule.dueMs = nextDailyRunMs(schedule.daily, this.#nowMs);
|
|
43
|
+
else
|
|
44
|
+
schedule.dueMs = nextCronRunMs(schedule.cronExpr, this.#nowMs) ?? Number.POSITIVE_INFINITY;
|
|
45
|
+
try {
|
|
46
|
+
await schedule.handler();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.#onError(error instanceof Error ? error.message : String(error));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
this.#nowMs = target;
|
|
53
|
+
}
|
|
54
|
+
computeDue(schedule) {
|
|
55
|
+
if (schedule.type === "every")
|
|
56
|
+
return this.#nowMs + (schedule.intervalMs ?? 60_000);
|
|
57
|
+
if (schedule.type === "daily")
|
|
58
|
+
return nextDailyRunMs(schedule.daily, this.#nowMs);
|
|
59
|
+
if (schedule.type === "cron")
|
|
60
|
+
return nextCronRunMs(schedule.cronExpr, this.#nowMs) ?? Number.POSITIVE_INFINITY;
|
|
61
|
+
return this.#nowMs + (schedule.intervalMs ?? 0);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Replace `{name}` placeholders, mirroring the host catalog's `interpolate`. */
|
|
65
|
+
function interpolateVars(template, vars) {
|
|
66
|
+
if (!vars)
|
|
67
|
+
return template;
|
|
68
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) => (key in vars ? String(vars[key]) : whole));
|
|
69
|
+
}
|
|
70
|
+
function parseDuration(amount) {
|
|
71
|
+
if (typeof amount === "number")
|
|
72
|
+
return amount;
|
|
73
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(amount.trim());
|
|
74
|
+
if (!match)
|
|
75
|
+
throw new Error(`Invalid duration: ${amount}`);
|
|
76
|
+
const value = Number(match[1]);
|
|
77
|
+
const unit = match[2];
|
|
78
|
+
return value * { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
|
|
79
|
+
}
|
|
80
|
+
function nextDailyRunMs(spec, fromMs) {
|
|
81
|
+
const [hour, minute] = spec.time.split(":").map(Number);
|
|
82
|
+
const from = new Date(fromMs);
|
|
83
|
+
for (let add = 0; add <= 7; add += 1) {
|
|
84
|
+
const next = new Date(fromMs);
|
|
85
|
+
next.setDate(from.getDate() + add);
|
|
86
|
+
next.setHours(hour ?? 0, minute ?? 0, 0, 0);
|
|
87
|
+
if (next.getTime() > fromMs && (!spec.days || spec.days.includes(next.getDay())))
|
|
88
|
+
return next.getTime();
|
|
89
|
+
}
|
|
90
|
+
return fromMs + 86_400_000;
|
|
91
|
+
}
|
|
92
|
+
/** Minimal 5-field cron next-run (m h dom mon dow), mirroring the host scheduler. */
|
|
93
|
+
export function nextCronRunMs(expr, fromMs) {
|
|
94
|
+
const fields = expr.trim().split(/\s+/);
|
|
95
|
+
if (fields.length !== 5)
|
|
96
|
+
throw new Error("Cron expressions must have 5 fields.");
|
|
97
|
+
const [minutes, hours, dom, months, dow] = fields.map((field, index) => {
|
|
98
|
+
const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
99
|
+
const [min, max] = ranges[index];
|
|
100
|
+
const values = new Set();
|
|
101
|
+
for (const part of field.split(",")) {
|
|
102
|
+
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
|
|
103
|
+
const base = stepMatch ? stepMatch[1] : part;
|
|
104
|
+
const step = stepMatch ? Number(stepMatch[2]) : 1;
|
|
105
|
+
let start = min;
|
|
106
|
+
let end = max;
|
|
107
|
+
if (base !== "*") {
|
|
108
|
+
const range = /^(\d+)-(\d+)$/.exec(base);
|
|
109
|
+
if (range) {
|
|
110
|
+
start = Number(range[1]);
|
|
111
|
+
end = Number(range[2]);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
start = Number(base);
|
|
115
|
+
end = stepMatch ? max : start;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end)
|
|
119
|
+
throw new Error("Invalid cron field.");
|
|
120
|
+
for (let value = start; value <= end; value += step)
|
|
121
|
+
values.add(index === 4 && value === 7 ? 0 : value);
|
|
122
|
+
}
|
|
123
|
+
return values;
|
|
124
|
+
});
|
|
125
|
+
const domWildcard = fields[2] === "*";
|
|
126
|
+
const dowWildcard = fields[4] === "*";
|
|
127
|
+
const candidate = new Date(fromMs);
|
|
128
|
+
candidate.setSeconds(0, 0);
|
|
129
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
130
|
+
const limit = fromMs + 4 * 366 * 86_400_000;
|
|
131
|
+
while (candidate.getTime() <= limit) {
|
|
132
|
+
if (!months.has(candidate.getMonth() + 1)) {
|
|
133
|
+
candidate.setMonth(candidate.getMonth() + 1, 1);
|
|
134
|
+
candidate.setHours(0, 0, 0, 0);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const dayMatch = domWildcard && dowWildcard ? true : domWildcard ? dow.has(candidate.getDay()) : dowWildcard ? dom.has(candidate.getDate()) : dom.has(candidate.getDate()) || dow.has(candidate.getDay());
|
|
138
|
+
if (!dayMatch) {
|
|
139
|
+
candidate.setDate(candidate.getDate() + 1);
|
|
140
|
+
candidate.setHours(0, 0, 0, 0);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!hours.has(candidate.getHours())) {
|
|
144
|
+
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!minutes.has(candidate.getMinutes())) {
|
|
148
|
+
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
return candidate.getTime();
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
export function createMockContext(optionsOrConfig = {}) {
|
|
156
|
+
const options = isMockOptions(optionsOrConfig) ? optionsOrConfig : { config: optionsOrConfig };
|
|
157
|
+
const approved = options.permissions === undefined ? null : new Set(options.permissions);
|
|
158
|
+
let config = { ...(options.config ?? {}) };
|
|
159
|
+
const calls = {
|
|
160
|
+
speak: [], react: [], reactions: [], statusReactions: [], status: [], storage: new Map(), schedules: new Map(), commands: new Map(), menuItems: [],
|
|
161
|
+
bubbles: [], alerts: [], dismissedBubbles: [], toasts: [], notifications: [], sounds: [], importedUserSounds: [], forgottenUserSounds: [], busPublishes: [], netCalls: [],
|
|
162
|
+
aiCalls: [], voiceSpeaks: [], openedExternal: [], clipboardWrites: [], spawnedPets: [], panelMessages: [],
|
|
163
|
+
savedFiles: [], secrets: new Map(), errors: [],
|
|
164
|
+
};
|
|
165
|
+
const clock = new FakeClock(options.nowMs ?? Date.now(), calls.schedules, (message) => calls.errors.push(message));
|
|
166
|
+
const eventSubscribers = new Map();
|
|
167
|
+
const storageSubscribers = new Map();
|
|
168
|
+
const busSubscribers = new Map();
|
|
169
|
+
const configListeners = new Set();
|
|
170
|
+
const panelToPluginHandlers = new Set();
|
|
171
|
+
const netMocks = [];
|
|
172
|
+
let aiResponder = null;
|
|
173
|
+
let pickableFiles = [];
|
|
174
|
+
let authTokens = null;
|
|
175
|
+
let listenText = null;
|
|
176
|
+
let clipboardText = "";
|
|
177
|
+
let systemInfo = { platform: "mac", locale: "en-US", timezone: "UTC", theme: "light", appVersion: "0.0.0-test", online: true };
|
|
178
|
+
let systemMetrics = { cpuPercent: 5, memUsedPercent: 40 };
|
|
179
|
+
let nextId = 0;
|
|
180
|
+
const newId = (prefix) => `${prefix}-${++nextId}`;
|
|
181
|
+
// ---- i18n: ctx.t / ctx.locale ----
|
|
182
|
+
// `ctx.locale` defaults to "en" and follows `harness.system.set({ locale })`.
|
|
183
|
+
// `ctx.t` resolves an optional in-memory catalog (active locale -> exact, then
|
|
184
|
+
// language prefix, then `en`), then echoes the key, finally interpolating {vars}.
|
|
185
|
+
const localeCatalogs = options.locales;
|
|
186
|
+
const activeLocale = () => {
|
|
187
|
+
const raw = systemInfo.locale;
|
|
188
|
+
return raw && raw !== "en-US" ? raw : "en";
|
|
189
|
+
};
|
|
190
|
+
const lookupCatalog = (key) => {
|
|
191
|
+
if (!localeCatalogs)
|
|
192
|
+
return undefined;
|
|
193
|
+
const locale = activeLocale();
|
|
194
|
+
const lang = locale.split(/[-_]/)[0];
|
|
195
|
+
return localeCatalogs[locale]?.[key] ?? localeCatalogs[lang]?.[key] ?? localeCatalogs.en?.[key];
|
|
196
|
+
};
|
|
197
|
+
const translate = (key, vars) => interpolateVars(lookupCatalog(key) ?? key, vars);
|
|
198
|
+
const requirePermission = (permission) => {
|
|
199
|
+
if (approved !== null && !approved.has(permission))
|
|
200
|
+
throw new Error(`Plugin permission is not approved: ${permission}`);
|
|
201
|
+
};
|
|
202
|
+
const makeBubble = (petId, spec) => {
|
|
203
|
+
requirePermission("pet:speak");
|
|
204
|
+
const normalized = typeof spec === "string" ? { text: spec } : { ...spec };
|
|
205
|
+
if (normalized.actions || normalized.input)
|
|
206
|
+
requirePermission("pet:interact");
|
|
207
|
+
if (normalized.pin)
|
|
208
|
+
requirePermission("pet:pin");
|
|
209
|
+
if (normalized.dynamic)
|
|
210
|
+
requirePermission("pet:speak:dynamic");
|
|
211
|
+
const id = newId("bubble");
|
|
212
|
+
const callbacks = {};
|
|
213
|
+
const record = {
|
|
214
|
+
spec: normalized,
|
|
215
|
+
petId,
|
|
216
|
+
updates: [],
|
|
217
|
+
pinned: normalized.pin === true,
|
|
218
|
+
dismissed: false,
|
|
219
|
+
handle: {
|
|
220
|
+
id,
|
|
221
|
+
update: async (patch) => { record.updates.push(patch); Object.assign(record.spec, patch); },
|
|
222
|
+
dismiss: async () => { if (!record.dismissed) {
|
|
223
|
+
record.dismissed = true;
|
|
224
|
+
calls.dismissedBubbles.push(id);
|
|
225
|
+
callbacks.onDismiss?.("manual");
|
|
226
|
+
} },
|
|
227
|
+
pin: async () => { requirePermission("pet:pin"); record.pinned = true; },
|
|
228
|
+
unpin: async () => { record.pinned = false; },
|
|
229
|
+
onAction: (handler) => { callbacks.onAction = handler; },
|
|
230
|
+
onSubmit: (handler) => { callbacks.onSubmit = handler; },
|
|
231
|
+
onDismiss: (handler) => { callbacks.onDismiss = handler; },
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
bubbleCallbacks.set(id, { record, callbacks });
|
|
235
|
+
calls.bubbles.push(record);
|
|
236
|
+
if (normalized.text)
|
|
237
|
+
calls.speak.push(normalized.text);
|
|
238
|
+
return record.handle;
|
|
239
|
+
};
|
|
240
|
+
const bubbleCallbacks = new Map();
|
|
241
|
+
const makeAlert = (spec) => {
|
|
242
|
+
if (spec.sound !== undefined)
|
|
243
|
+
requirePermission("audio");
|
|
244
|
+
if (spec.notify !== undefined)
|
|
245
|
+
requirePermission("notify");
|
|
246
|
+
const { sound, notify, indicator, ...bubbleSpec } = spec;
|
|
247
|
+
const bubble = makeBubble("default", {
|
|
248
|
+
...bubbleSpec,
|
|
249
|
+
...(indicator === false ? {} : { indicator }),
|
|
250
|
+
sticky: true,
|
|
251
|
+
priority: "high",
|
|
252
|
+
});
|
|
253
|
+
const bubbleRecord = calls.bubbles[calls.bubbles.length - 1];
|
|
254
|
+
if (sound !== undefined)
|
|
255
|
+
calls.sounds.push({ sound });
|
|
256
|
+
if (notify !== undefined)
|
|
257
|
+
calls.notifications.push({ title: notify.title, body: notify.body });
|
|
258
|
+
const id = newId("alert");
|
|
259
|
+
const record = {
|
|
260
|
+
spec: { ...spec, actions: spec.actions?.map((action) => ({ ...action })) },
|
|
261
|
+
bubble: bubbleRecord,
|
|
262
|
+
updates: [],
|
|
263
|
+
dismissed: false,
|
|
264
|
+
acknowledged: false,
|
|
265
|
+
handle: {
|
|
266
|
+
id,
|
|
267
|
+
update: async (patch) => { record.updates.push(patch); Object.assign(record.spec, patch); await bubble.update(patch); },
|
|
268
|
+
dismiss: async () => { if (!record.dismissed) {
|
|
269
|
+
record.dismissed = true;
|
|
270
|
+
await bubble.dismiss();
|
|
271
|
+
} },
|
|
272
|
+
pin: async () => bubble.pin(),
|
|
273
|
+
unpin: async () => bubble.unpin(),
|
|
274
|
+
onAction: (handler) => { bubble.onAction(handler); },
|
|
275
|
+
onSubmit: (handler) => { bubble.onSubmit(handler); },
|
|
276
|
+
onDismiss: (handler) => { bubble.onDismiss(handler); },
|
|
277
|
+
acknowledge: async () => { record.acknowledged = true; await record.handle.dismiss(); },
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
calls.alerts.push(record);
|
|
281
|
+
return record.handle;
|
|
282
|
+
};
|
|
283
|
+
const petInfos = [{ id: "default", name: "Default pet", kind: "default", visible: true }];
|
|
284
|
+
const petState = { position: { x: 100, y: 100 }, bounds: { x: 100, y: 100, width: 220, height: 240 }, currentAnimation: "idle", visible: true, dragging: false };
|
|
285
|
+
const tickHandlers = new Set();
|
|
286
|
+
const makePetHandle = (petId) => ({
|
|
287
|
+
id: petId,
|
|
288
|
+
speak: async (spec) => makeBubble(petId, spec),
|
|
289
|
+
react: async (reaction, options) => { requirePermission("pet:reaction"); assertReaction(reaction); const record = { reaction: String(reaction), ...(options === undefined ? {} : { options: { ...options } }) }; calls.react.push(record.reaction); calls.reactions.push(record); },
|
|
290
|
+
setAnimation: async (state) => { if (typeof state === "string") {
|
|
291
|
+
requirePermission("pet:reaction");
|
|
292
|
+
calls.react.push(String(state));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
requirePermission("pet:animate");
|
|
296
|
+
petState.currentAnimation = `sprite:${state.sprite.name}`;
|
|
297
|
+
} },
|
|
298
|
+
setScale: async () => { requirePermission("pet:animate"); },
|
|
299
|
+
setStatusReaction: async (reaction) => { requirePermission("pet:reaction"); if (reaction !== null)
|
|
300
|
+
assertReaction(reaction); calls.statusReactions.push(reaction === null ? null : String(reaction)); },
|
|
301
|
+
moveBy: async () => { requirePermission("pet:move"); },
|
|
302
|
+
wander: async () => { requirePermission("pet:move"); },
|
|
303
|
+
moveToHome: async () => { requirePermission("pet:move"); },
|
|
304
|
+
moveTo: async (point) => { requirePermission("pet:move"); petState.position = { ...point }; },
|
|
305
|
+
followCursor: async () => { requirePermission("pet:move"); },
|
|
306
|
+
physics: async () => { requirePermission("pet:move"); },
|
|
307
|
+
onTick: (handler) => { requirePermission("events"); tickHandlers.add(handler); return () => tickHandlers.delete(handler); },
|
|
308
|
+
getState: async () => { requirePermission("pets:read"); return { ...petState, position: { ...petState.position }, bounds: { ...petState.bounds } }; },
|
|
309
|
+
show: async () => { requirePermission("pets:manage"); },
|
|
310
|
+
hide: async () => { requirePermission("pets:manage"); },
|
|
311
|
+
close: async () => { requirePermission("pets:manage"); },
|
|
312
|
+
});
|
|
313
|
+
const matchNetMock = (url) => netMocks.find((mock) => url.startsWith(mock.urlPrefix));
|
|
314
|
+
const ctx = {
|
|
315
|
+
pet: makePetHandle("default"),
|
|
316
|
+
pets: {
|
|
317
|
+
default: makePetHandle("default"),
|
|
318
|
+
list: async () => { requirePermission("pets:read"); return petInfos.map((info) => ({ ...info })); },
|
|
319
|
+
get: (petId) => makePetHandle(petId),
|
|
320
|
+
spawn: async (spec) => { requirePermission("pets:manage"); const id = newId("pet"); calls.spawnedPets.push(spec.petId); petInfos.push({ id, name: spec.name ?? spec.petId, kind: "plugin", visible: true }); return makePetHandle(id); },
|
|
321
|
+
onChange: () => () => undefined,
|
|
322
|
+
},
|
|
323
|
+
ui: {
|
|
324
|
+
bubble: async (spec) => makeBubble("default", spec),
|
|
325
|
+
toast: async (spec) => { requirePermission("ui:toast"); calls.toasts.push({ text: spec.text, tone: spec.tone }); },
|
|
326
|
+
alert: async (spec) => makeAlert(spec),
|
|
327
|
+
panel: async () => {
|
|
328
|
+
requirePermission("ui:panel");
|
|
329
|
+
const id = newId("panel");
|
|
330
|
+
return {
|
|
331
|
+
id,
|
|
332
|
+
show: async () => undefined,
|
|
333
|
+
hide: async () => undefined,
|
|
334
|
+
postMessage: async (msg) => { calls.panelMessages.push(msg); },
|
|
335
|
+
onMessage: (handler) => { panelToPluginHandlers.add(handler); },
|
|
336
|
+
close: async () => undefined,
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
menu: {
|
|
340
|
+
setItems: async (items) => { requirePermission("commands"); calls.menuItems = items.map((item) => ({ ...item })); },
|
|
341
|
+
onSelect: () => () => undefined,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
audio: {
|
|
345
|
+
play: async (sound, options) => { requirePermission("audio"); calls.sounds.push({ sound, volume: options?.volume }); },
|
|
346
|
+
importUserSound: async (file, opts) => {
|
|
347
|
+
requirePermission("audio");
|
|
348
|
+
requirePermission("files");
|
|
349
|
+
const ref = { kind: "user-sound", id: newId("sound"), name: opts?.name ?? file.name };
|
|
350
|
+
calls.importedUserSounds.push({ ref, fileName: file.name, name: opts?.name });
|
|
351
|
+
return ref;
|
|
352
|
+
},
|
|
353
|
+
forgetUserSound: async (ref) => { requirePermission("audio"); calls.forgottenUserSounds.push(ref); },
|
|
354
|
+
stop: async () => { requirePermission("audio"); },
|
|
355
|
+
},
|
|
356
|
+
events: {
|
|
357
|
+
on: (event, handler) => {
|
|
358
|
+
requirePermission("events");
|
|
359
|
+
if (event === "pet:drop")
|
|
360
|
+
requirePermission("pet:drop");
|
|
361
|
+
let subscribers = eventSubscribers.get(event);
|
|
362
|
+
if (!subscribers) {
|
|
363
|
+
subscribers = new Set();
|
|
364
|
+
eventSubscribers.set(event, subscribers);
|
|
365
|
+
}
|
|
366
|
+
const wrapped = (payload) => handler(payload);
|
|
367
|
+
subscribers.add(wrapped);
|
|
368
|
+
return () => subscribers.delete(wrapped);
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
assets: {
|
|
372
|
+
icon: (name) => ({ kind: "icon", name }),
|
|
373
|
+
image: (name) => ({ kind: "image", name }),
|
|
374
|
+
svg: (name) => ({ kind: "svg", name }),
|
|
375
|
+
sprite: (name) => ({ kind: "sprite", name }),
|
|
376
|
+
sound: (name) => ({ kind: "sound", name }),
|
|
377
|
+
},
|
|
378
|
+
bus: {
|
|
379
|
+
publish: async (topic, payload) => { requirePermission("bus"); calls.busPublishes.push({ topic, payload }); for (const handler of busSubscribers.get(topic) ?? [])
|
|
380
|
+
handler(payload); },
|
|
381
|
+
subscribe: (topic, handler) => {
|
|
382
|
+
requirePermission("bus");
|
|
383
|
+
let subscribers = busSubscribers.get(topic);
|
|
384
|
+
if (!subscribers) {
|
|
385
|
+
subscribers = new Set();
|
|
386
|
+
busSubscribers.set(topic, subscribers);
|
|
387
|
+
}
|
|
388
|
+
subscribers.add(handler);
|
|
389
|
+
return () => subscribers.delete(handler);
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
schedule: {
|
|
393
|
+
once: async (id, delayMs, handler) => { requirePermission("schedule"); calls.schedules.set(id, { type: "once", handler, dueMs: clock.now() + delayMs, intervalMs: delayMs }); },
|
|
394
|
+
every: async (id, intervalMs, handler) => { requirePermission("schedule"); calls.schedules.set(id, { type: "every", handler, dueMs: clock.now() + intervalMs, intervalMs }); },
|
|
395
|
+
daily: async (id, spec, handler) => { requirePermission("schedule"); const daily = typeof spec === "string" ? { time: spec } : spec; calls.schedules.set(id, { type: "daily", handler, daily, dueMs: nextDailyRunMs(daily, clock.now()) }); },
|
|
396
|
+
cron: async (id, expr, handler) => { requirePermission("schedule"); const due = nextCronRunMs(expr, clock.now()); calls.schedules.set(id, { type: "cron", handler, cronExpr: expr, dueMs: due ?? Number.POSITIVE_INFINITY }); },
|
|
397
|
+
at: async (id, isoTimestamp, handler) => { requirePermission("schedule"); const due = Date.parse(isoTimestamp); calls.schedules.set(id, { type: "at", handler, dueMs: Number.isFinite(due) ? Math.max(due, clock.now()) : clock.now() }); },
|
|
398
|
+
list: async () => [...calls.schedules.entries()].map(([id, schedule]) => ({ id, nextRunMs: schedule.dueMs })),
|
|
399
|
+
cancel: async (id) => void calls.schedules.delete(id),
|
|
400
|
+
cancelAll: async () => void calls.schedules.clear(),
|
|
401
|
+
},
|
|
402
|
+
storage: {
|
|
403
|
+
get: async (key) => { requirePermission("storage"); return calls.storage.get(key); },
|
|
404
|
+
set: async (key, value) => { requirePermission("storage"); calls.storage.set(key, value); for (const handler of storageSubscribers.get(key) ?? [])
|
|
405
|
+
handler(value); },
|
|
406
|
+
delete: async (key) => { requirePermission("storage"); calls.storage.delete(key); for (const handler of storageSubscribers.get(key) ?? [])
|
|
407
|
+
handler(undefined); },
|
|
408
|
+
keys: async () => { requirePermission("storage"); return [...calls.storage.keys()]; },
|
|
409
|
+
subscribe: (key, handler) => {
|
|
410
|
+
requirePermission("storage");
|
|
411
|
+
let subscribers = storageSubscribers.get(key);
|
|
412
|
+
if (!subscribers) {
|
|
413
|
+
subscribers = new Set();
|
|
414
|
+
storageSubscribers.set(key, subscribers);
|
|
415
|
+
}
|
|
416
|
+
subscribers.add(handler);
|
|
417
|
+
return () => subscribers.delete(handler);
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
config: {
|
|
421
|
+
get: async () => ({ ...config }),
|
|
422
|
+
onChange: (handler) => { configListeners.add(handler); return () => configListeners.delete(handler); },
|
|
423
|
+
},
|
|
424
|
+
net: {
|
|
425
|
+
fetch: async (url, options) => {
|
|
426
|
+
requirePermission("network");
|
|
427
|
+
if (options?.method && options.method !== "GET")
|
|
428
|
+
requirePermission("network:write");
|
|
429
|
+
calls.netCalls.push({ url, method: options?.method ?? "GET", headers: options?.headers, body: options?.body, stream: false });
|
|
430
|
+
const mock = matchNetMock(url);
|
|
431
|
+
if (!mock)
|
|
432
|
+
throw new Error(`No net mock for ${url} — call harness.net.mock(...) first.`);
|
|
433
|
+
const text = mock.response.text ?? (mock.response.json !== undefined ? JSON.stringify(mock.response.json) : "");
|
|
434
|
+
return { status: mock.response.status ?? 200, ok: (mock.response.status ?? 200) < 400, headers: { "content-type": mock.response.json !== undefined ? "application/json" : "text/plain" }, text, json: mock.response.json };
|
|
435
|
+
},
|
|
436
|
+
stream: async (url, options, onChunk) => {
|
|
437
|
+
requirePermission("network");
|
|
438
|
+
if (options.method && options.method !== "GET")
|
|
439
|
+
requirePermission("network:write");
|
|
440
|
+
calls.netCalls.push({ url, method: options.method ?? "GET", headers: options.headers, body: options.body, stream: true });
|
|
441
|
+
const mock = matchNetMock(url);
|
|
442
|
+
if (!mock)
|
|
443
|
+
throw new Error(`No net mock for ${url} — call harness.net.mock(...) first.`);
|
|
444
|
+
for (const chunk of mock.response.chunks ?? [mock.response.text ?? ""])
|
|
445
|
+
if (chunk)
|
|
446
|
+
onChunk(chunk);
|
|
447
|
+
return { status: mock.response.status ?? 200, ok: (mock.response.status ?? 200) < 400 };
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
notify: {
|
|
451
|
+
notify: async (spec) => { requirePermission("notify"); calls.notifications.push({ title: spec.title, body: spec.body }); },
|
|
452
|
+
},
|
|
453
|
+
ai: {
|
|
454
|
+
available: async () => { requirePermission("ai"); return aiResponder !== null; },
|
|
455
|
+
complete: async (req) => {
|
|
456
|
+
requirePermission("ai");
|
|
457
|
+
calls.aiCalls.push({ system: req.system, messages: req.messages });
|
|
458
|
+
if (!aiResponder)
|
|
459
|
+
throw new Error("No AI mock — call harness.ai.mock(...) first.");
|
|
460
|
+
return { text: aiResponder(req) };
|
|
461
|
+
},
|
|
462
|
+
stream: async (req, onToken) => {
|
|
463
|
+
requirePermission("ai");
|
|
464
|
+
calls.aiCalls.push({ system: req.system, messages: req.messages });
|
|
465
|
+
if (!aiResponder)
|
|
466
|
+
throw new Error("No AI mock — call harness.ai.mock(...) first.");
|
|
467
|
+
const text = aiResponder(req);
|
|
468
|
+
for (const token of text.split(/(?<=\s)/))
|
|
469
|
+
onToken(token);
|
|
470
|
+
return { text };
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
secrets: {
|
|
474
|
+
set: async (key, value) => { requirePermission("secrets"); calls.secrets.set(key, value); },
|
|
475
|
+
get: async (key) => { requirePermission("secrets"); return calls.secrets.get(key); },
|
|
476
|
+
delete: async (key) => { requirePermission("secrets"); calls.secrets.delete(key); },
|
|
477
|
+
has: async (key) => { requirePermission("secrets"); return calls.secrets.has(key); },
|
|
478
|
+
},
|
|
479
|
+
voice: {
|
|
480
|
+
speak: async (text) => { requirePermission("voice:speak"); calls.voiceSpeaks.push(text); },
|
|
481
|
+
listen: async () => { requirePermission("voice:listen"); if (listenText === null)
|
|
482
|
+
throw new Error("No listen mock — call harness.voice.mockListen(...) first."); return { text: listenText }; },
|
|
483
|
+
},
|
|
484
|
+
auth: {
|
|
485
|
+
oauth: async () => { requirePermission("auth"); if (!authTokens)
|
|
486
|
+
throw new Error("No auth mock — call harness.auth.mock(...) first."); return { ...authTokens }; },
|
|
487
|
+
refresh: async () => { requirePermission("auth"); if (!authTokens)
|
|
488
|
+
throw new Error("No auth mock — call harness.auth.mock(...) first."); return { accessToken: authTokens.accessToken, expiresAt: authTokens.expiresAt }; },
|
|
489
|
+
signOut: async () => { requirePermission("auth"); authTokens = null; },
|
|
490
|
+
},
|
|
491
|
+
files: {
|
|
492
|
+
pick: async () => {
|
|
493
|
+
requirePermission("files");
|
|
494
|
+
return pickableFiles.map((file) => ({
|
|
495
|
+
name: file.name,
|
|
496
|
+
sizeBytes: file.bytes?.byteLength ?? (file.text ? file.text.length : 0),
|
|
497
|
+
readText: async () => file.text ?? new TextDecoder().decode(file.bytes ?? new Uint8Array()),
|
|
498
|
+
readBytes: async () => file.bytes ?? new TextEncoder().encode(file.text ?? ""),
|
|
499
|
+
}));
|
|
500
|
+
},
|
|
501
|
+
save: async (opts) => { requirePermission("files"); calls.savedFiles.push(opts); },
|
|
502
|
+
},
|
|
503
|
+
system: {
|
|
504
|
+
info: async () => ({ ...systemInfo }),
|
|
505
|
+
metrics: async () => { requirePermission("system:metrics"); return { ...systemMetrics }; },
|
|
506
|
+
openExternal: async (url) => { requirePermission("system:openExternal"); if (!url.startsWith("https://"))
|
|
507
|
+
throw new Error("openExternal requires an HTTPS URL."); calls.openedExternal.push(url); },
|
|
508
|
+
readClipboardText: async () => { requirePermission("clipboard"); return clipboardText; },
|
|
509
|
+
writeClipboardText: async (text) => { requirePermission("clipboard"); clipboardText = text; calls.clipboardWrites.push(text); },
|
|
510
|
+
},
|
|
511
|
+
commands: {
|
|
512
|
+
register: async (command, handler) => { requirePermission("commands"); calls.commands.set(command.id, { meta: command, handler }); },
|
|
513
|
+
unregister: async (id) => void calls.commands.delete(id),
|
|
514
|
+
},
|
|
515
|
+
status: {
|
|
516
|
+
set: async (status) => { requirePermission("status"); calls.status.push(status); },
|
|
517
|
+
clear: async () => { requirePermission("status"); },
|
|
518
|
+
},
|
|
519
|
+
http: {
|
|
520
|
+
fetch: async (url, options) => {
|
|
521
|
+
requirePermission("network");
|
|
522
|
+
calls.netCalls.push({ url, method: "GET", headers: options?.headers, stream: false });
|
|
523
|
+
const mock = matchNetMock(url);
|
|
524
|
+
if (!mock)
|
|
525
|
+
return { status: 200, ok: true, headers: {}, text: "" };
|
|
526
|
+
const text = mock.response.text ?? (mock.response.json !== undefined ? JSON.stringify(mock.response.json) : "");
|
|
527
|
+
return { status: mock.response.status ?? 200, ok: (mock.response.status ?? 200) < 400, headers: {}, text, json: mock.response.json };
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
log: {
|
|
531
|
+
debug: async () => undefined,
|
|
532
|
+
info: async () => undefined,
|
|
533
|
+
warn: async () => undefined,
|
|
534
|
+
error: async () => undefined,
|
|
535
|
+
},
|
|
536
|
+
t: (key, vars) => translate(key, vars),
|
|
537
|
+
get locale() { return activeLocale(); },
|
|
538
|
+
};
|
|
539
|
+
const harness = {
|
|
540
|
+
clock,
|
|
541
|
+
emit: async (event, payload) => { for (const handler of eventSubscribers.get(event) ?? [])
|
|
542
|
+
await Promise.resolve(handler(payload)); },
|
|
543
|
+
fireBubbleAction: async (bubbleId, actionId) => { const entry = bubbleCallbacks.get(bubbleId); if (!entry)
|
|
544
|
+
throw new Error(`Unknown bubble: ${bubbleId}`); await entry.callbacks.onAction?.(actionId); const action = entry.record.spec.actions?.find((candidate) => candidate.id === actionId); if (action?.dismissesBubble !== false)
|
|
545
|
+
await entry.record.handle.dismiss(); },
|
|
546
|
+
fireBubbleSubmit: async (bubbleId, values) => { const entry = bubbleCallbacks.get(bubbleId); if (!entry)
|
|
547
|
+
throw new Error(`Unknown bubble: ${bubbleId}`); await entry.callbacks.onSubmit?.(values); },
|
|
548
|
+
dismissBubble: async (bubbleId, reason = "click") => { const entry = bubbleCallbacks.get(bubbleId); if (!entry || entry.record.dismissed)
|
|
549
|
+
return; entry.record.dismissed = true; calls.dismissedBubbles.push(bubbleId); entry.callbacks.onDismiss?.(reason); },
|
|
550
|
+
runCommand: async (commandId, values) => { const command = calls.commands.get(commandId); if (!command)
|
|
551
|
+
throw new Error(`Unknown command: ${commandId}`); await command.handler(values); },
|
|
552
|
+
setConfig: async (next) => { config = { ...next }; for (const listener of configListeners)
|
|
553
|
+
await Promise.resolve(listener({ ...config })); },
|
|
554
|
+
net: { mock: (urlPrefix, response) => { netMocks.unshift({ urlPrefix, response }); } },
|
|
555
|
+
ai: { mock: (responder) => { aiResponder = responder; } },
|
|
556
|
+
files: { provide: (files) => { pickableFiles = files; } },
|
|
557
|
+
auth: { mock: (tokens) => { authTokens = tokens; } },
|
|
558
|
+
voice: { mockListen: (text) => { listenText = text; } },
|
|
559
|
+
system: {
|
|
560
|
+
set: (info) => { systemInfo = { ...systemInfo, ...info }; },
|
|
561
|
+
setMetrics: (metrics) => { systemMetrics = metrics; },
|
|
562
|
+
setClipboard: (text) => { clipboardText = text; },
|
|
563
|
+
},
|
|
564
|
+
panel: { sendToPlugin: (msg) => { for (const handler of panelToPluginHandlers)
|
|
565
|
+
handler(msg); } },
|
|
566
|
+
};
|
|
567
|
+
return { ctx, calls, harness };
|
|
568
|
+
}
|
|
569
|
+
function isMockOptions(value) {
|
|
570
|
+
return "permissions" in value || "config" in value || "nowMs" in value || "locales" in value;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Build a batteries-included harness around a plugin definition or an exported
|
|
574
|
+
* `register(OpenPetsPlugin)` entry function.
|
|
575
|
+
*/
|
|
576
|
+
export function createTestHarness(plugin, options = {}) {
|
|
577
|
+
const { ctx, calls, harness } = createMockContext(options);
|
|
578
|
+
let definition = typeof plugin === "function" ? null : plugin;
|
|
579
|
+
const entry = typeof plugin === "function" ? plugin : null;
|
|
580
|
+
const fail = (message) => { throw new Error(message); };
|
|
581
|
+
return {
|
|
582
|
+
...harness,
|
|
583
|
+
ctx,
|
|
584
|
+
calls,
|
|
585
|
+
async start() {
|
|
586
|
+
if (entry) {
|
|
587
|
+
const api = { register: (registered) => { definition = registered; } };
|
|
588
|
+
await entry(api);
|
|
589
|
+
if (!definition)
|
|
590
|
+
fail("Plugin entry never called OpenPetsPlugin.register().");
|
|
591
|
+
}
|
|
592
|
+
await definition.start(ctx);
|
|
593
|
+
},
|
|
594
|
+
async stop() {
|
|
595
|
+
await definition?.stop?.(ctx);
|
|
596
|
+
},
|
|
597
|
+
async tick(dtMs) {
|
|
598
|
+
// The mock pet handle records tick handlers internally; route through emit-like dispatch.
|
|
599
|
+
await harness.emit("pet:hover", { petId: "default", __tickDtMs: dtMs });
|
|
600
|
+
},
|
|
601
|
+
expectSpoke(matcher) {
|
|
602
|
+
const found = calls.speak.some((message) => (typeof matcher === "string" ? message.includes(matcher) : matcher.test(message)));
|
|
603
|
+
if (!found)
|
|
604
|
+
fail(`Expected pet speech matching ${String(matcher)}; got ${JSON.stringify(calls.speak)}`);
|
|
605
|
+
},
|
|
606
|
+
expectReacted(reaction) {
|
|
607
|
+
if (!calls.react.includes(reaction))
|
|
608
|
+
fail(`Expected reaction "${reaction}"; got ${JSON.stringify(calls.react)}`);
|
|
609
|
+
},
|
|
610
|
+
expectScheduled(id) {
|
|
611
|
+
if (!calls.schedules.has(id))
|
|
612
|
+
fail(`Expected schedule "${id}"; got ${JSON.stringify([...calls.schedules.keys()])}`);
|
|
613
|
+
},
|
|
614
|
+
expectBubble(matcher) {
|
|
615
|
+
const { textMatch, ...rest } = matcher;
|
|
616
|
+
const found = calls.bubbles.some((bubble) => {
|
|
617
|
+
if (textMatch && !(bubble.spec.text && textMatch.test(bubble.spec.text)))
|
|
618
|
+
return false;
|
|
619
|
+
return Object.entries(rest).every(([key, value]) => JSON.stringify(bubble.spec[key]) === JSON.stringify(value));
|
|
620
|
+
});
|
|
621
|
+
if (!found)
|
|
622
|
+
fail(`Expected a bubble matching ${JSON.stringify(matcher)}; got ${JSON.stringify(calls.bubbles.map((bubble) => bubble.spec))}`);
|
|
623
|
+
},
|
|
624
|
+
expectStored(key, predicate) {
|
|
625
|
+
if (!calls.storage.has(key))
|
|
626
|
+
fail(`Expected storage key "${key}"; got ${JSON.stringify([...calls.storage.keys()])}`);
|
|
627
|
+
if (predicate && !predicate(calls.storage.get(key)))
|
|
628
|
+
fail(`Storage value for "${key}" failed the predicate: ${JSON.stringify(calls.storage.get(key))}`);
|
|
629
|
+
},
|
|
630
|
+
expectNetCall(urlSubstring) {
|
|
631
|
+
if (!calls.netCalls.some((call) => call.url.includes(urlSubstring)))
|
|
632
|
+
fail(`Expected a network call containing "${urlSubstring}"; got ${JSON.stringify(calls.netCalls.map((call) => call.url))}`);
|
|
633
|
+
},
|
|
634
|
+
expectNotified(matcher) {
|
|
635
|
+
const found = calls.notifications.some((notification) => (typeof matcher === "string" ? notification.title.includes(matcher) || (notification.body ?? "").includes(matcher) : matcher.test(notification.title) || matcher.test(notification.body ?? "")));
|
|
636
|
+
if (!found)
|
|
637
|
+
fail(`Expected a notification matching ${String(matcher)}; got ${JSON.stringify(calls.notifications)}`);
|
|
638
|
+
},
|
|
639
|
+
expectNoErrors() {
|
|
640
|
+
if (calls.errors.length > 0)
|
|
641
|
+
fail(`Expected no schedule/handler errors; got ${JSON.stringify(calls.errors)}`);
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
//# sourceMappingURL=testing.js.map
|