@owncast/plugin-sdk 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/owncast-plugin.js +15 -0
- package/index.d.ts +169 -3
- package/index.js +303 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +16 -3
- package/testing.js +56 -9
package/bin/owncast-plugin.js
CHANGED
|
@@ -366,6 +366,13 @@ function generateInterface(manifest) {
|
|
|
366
366
|
|
|
367
367
|
const perms = new Set(manifest.permissions || []);
|
|
368
368
|
const imports = [];
|
|
369
|
+
// Timers are ambient (no permission): the host always provides them, since
|
|
370
|
+
// a plugin can't setTimeout in the sandbox.
|
|
371
|
+
imports.push("owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32");
|
|
372
|
+
imports.push("owncast_timer_clear(id: I64): void");
|
|
373
|
+
// Config is ambient too: a plugin reading its own manifest-declared config
|
|
374
|
+
// (admin override falling back to the declared default) needs no permission.
|
|
375
|
+
imports.push("owncast_config_get(keyPtr: PTR): PTR");
|
|
369
376
|
if (perms.has("chat.send")) {
|
|
370
377
|
imports.push("owncast_send_chat(textPtr: PTR): void");
|
|
371
378
|
imports.push("owncast_send_chat_action(textPtr: PTR): void");
|
|
@@ -398,6 +405,13 @@ function generateInterface(manifest) {
|
|
|
398
405
|
if (perms.has("storage.upload")) {
|
|
399
406
|
imports.push("owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR");
|
|
400
407
|
}
|
|
408
|
+
if (perms.has("storage.fs")) {
|
|
409
|
+
imports.push("owncast_fs_read(pathPtr: PTR): PTR");
|
|
410
|
+
imports.push("owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR");
|
|
411
|
+
imports.push("owncast_fs_list(dirPtr: PTR): PTR");
|
|
412
|
+
imports.push("owncast_fs_delete(pathPtr: PTR): PTR");
|
|
413
|
+
imports.push("owncast_fs_exists(pathPtr: PTR): I32");
|
|
414
|
+
}
|
|
401
415
|
if (perms.has("fediverse.post")) {
|
|
402
416
|
imports.push("owncast_fediverse_post(textPtr: PTR): PTR");
|
|
403
417
|
}
|
|
@@ -417,6 +431,7 @@ function generateInterface(manifest) {
|
|
|
417
431
|
imports.push("owncast_stream_current(): PTR");
|
|
418
432
|
imports.push("owncast_server_info(): PTR");
|
|
419
433
|
imports.push("owncast_server_socials(): PTR");
|
|
434
|
+
imports.push("owncast_server_emotes(): PTR");
|
|
420
435
|
imports.push("owncast_server_federation(): PTR");
|
|
421
436
|
imports.push("owncast_stream_broadcaster(): PTR");
|
|
422
437
|
imports.push("owncast_server_tags(): PTR");
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Built-in chat message payload (`chat.message.received` and the chat filter).
|
|
3
|
+
*
|
|
4
|
+
* `user` carries the full sender identity — use `user.id` for stable per-user
|
|
5
|
+
* state and `user.scopes` (e.g. `"MODERATOR"`) for reliable, non-spoofable
|
|
6
|
+
* moderation gating rather than matching on the display name. `clientId`
|
|
7
|
+
* identifies the originating connection; pass it to `owncast.chat.sendTo` (or
|
|
8
|
+
* `owncast.chat.replyTo(msg, …)`) to whisper a reply back to the sender.
|
|
9
|
+
*
|
|
10
|
+
* `user` is undefined for the rare message with no associated account.
|
|
11
|
+
*/
|
|
2
12
|
export interface ChatMessage {
|
|
3
13
|
id: string;
|
|
4
|
-
user
|
|
14
|
+
user?: ChatUser;
|
|
15
|
+
clientId?: number;
|
|
5
16
|
body: string;
|
|
6
17
|
timestamp: string;
|
|
7
18
|
}
|
|
@@ -115,6 +126,9 @@ export const Events: {
|
|
|
115
126
|
readonly StreamStarted: "stream.started";
|
|
116
127
|
readonly StreamStopped: "stream.stopped";
|
|
117
128
|
readonly StreamTitleChanged: "stream.title.changed";
|
|
129
|
+
readonly SseConnect: "sse.connect";
|
|
130
|
+
readonly SseDisconnect: "sse.disconnect";
|
|
131
|
+
readonly Tick: "tick";
|
|
118
132
|
readonly FediverseFollow: "fediverse.follow";
|
|
119
133
|
readonly FediverseLike: "fediverse.like";
|
|
120
134
|
readonly FediverseRepost: "fediverse.repost";
|
|
@@ -161,6 +175,7 @@ export const Permissions: {
|
|
|
161
175
|
readonly ChatFilter: "chat.filter";
|
|
162
176
|
readonly StorageKV: "storage.kv";
|
|
163
177
|
readonly StorageUpload: "storage.upload";
|
|
178
|
+
readonly StorageFS: "storage.fs";
|
|
164
179
|
readonly EventsEmit: "events.emit";
|
|
165
180
|
readonly NetworkFetch: "network.fetch";
|
|
166
181
|
readonly HttpServe: "http.serve";
|
|
@@ -194,6 +209,12 @@ export interface SocialHandle {
|
|
|
194
209
|
icon?: string;
|
|
195
210
|
}
|
|
196
211
|
|
|
212
|
+
/** A custom chat emote from owncast.server.emotes(). */
|
|
213
|
+
export interface Emote {
|
|
214
|
+
name: string; // the `:code:` chat clients substitute
|
|
215
|
+
url: string; // image the emote renders to
|
|
216
|
+
}
|
|
217
|
+
|
|
197
218
|
export interface FederationInfo {
|
|
198
219
|
enabled: boolean;
|
|
199
220
|
username?: string;
|
|
@@ -228,6 +249,13 @@ export interface UploadResult {
|
|
|
228
249
|
url: string;
|
|
229
250
|
}
|
|
230
251
|
|
|
252
|
+
/** Result of a mutating owncast.fs call (write/delete). `ok` is false and
|
|
253
|
+
* `error` is set when the host rejected the operation. */
|
|
254
|
+
export interface FsResult {
|
|
255
|
+
ok: boolean;
|
|
256
|
+
error?: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
231
259
|
export const filter: {
|
|
232
260
|
pass(): FilterResult;
|
|
233
261
|
modify(payload: any): FilterResult;
|
|
@@ -256,6 +284,24 @@ export interface OutgoingHttpResponse {
|
|
|
256
284
|
body?: string;
|
|
257
285
|
}
|
|
258
286
|
|
|
287
|
+
/** Payload for the sse.connect / sse.disconnect events. Fired when a browser
|
|
288
|
+
* opens or closes one of the plugin's `/plugins/<name>/_sse/<channel>`
|
|
289
|
+
* streams, so the plugin can track who is connected. `connectionId` is unique
|
|
290
|
+
* per connection for the life of the host process, so a disconnect can be
|
|
291
|
+
* paired with its connect and the same user counted across several tabs.
|
|
292
|
+
* `user` is present only when the connection carried a chat identity. */
|
|
293
|
+
export interface SSEConnectionEvent {
|
|
294
|
+
channel: string;
|
|
295
|
+
connectionId: number;
|
|
296
|
+
user?: ChatUser;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Payload for the once-a-second tick event (onTick). `now` is the host
|
|
300
|
+
* wall-clock time in unix milliseconds when the tick fired. */
|
|
301
|
+
export interface TickEvent {
|
|
302
|
+
now: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
259
305
|
export interface PluginDef {
|
|
260
306
|
/** Notification handler for chat messages. Fire-and-forget. */
|
|
261
307
|
onChatMessage?(msg: ChatMessage): void | Promise<void>;
|
|
@@ -280,6 +326,17 @@ export interface PluginDef {
|
|
|
280
326
|
/** Stream title was updated. */
|
|
281
327
|
onStreamTitleChanged?(change: StreamTitleChange): void | Promise<void>;
|
|
282
328
|
|
|
329
|
+
/** A browser opened one of this plugin's SSE streams. Use it to track who
|
|
330
|
+
* is connected. Requires the `http.sse` permission. */
|
|
331
|
+
onSseConnect?(event: SSEConnectionEvent): void | Promise<void>;
|
|
332
|
+
/** A browser closed one of this plugin's SSE streams (same connectionId as
|
|
333
|
+
* the matching onSseConnect). Requires the `http.sse` permission. */
|
|
334
|
+
onSseDisconnect?(event: SSEConnectionEvent): void | Promise<void>;
|
|
335
|
+
|
|
336
|
+
/** Fires once a second for periodic work. `now` is the host wall-clock time
|
|
337
|
+
* in unix milliseconds. Defining this opts the plugin into the tick. */
|
|
338
|
+
onTick?(event: TickEvent): void | Promise<void>;
|
|
339
|
+
|
|
283
340
|
/** Someone on the fediverse followed the streamer's account. */
|
|
284
341
|
onFediverseFollow?(event: FediverseEngagement): void | Promise<void>;
|
|
285
342
|
/** Someone on the fediverse liked a streamer post / federated stream announcement. */
|
|
@@ -308,6 +365,62 @@ export interface PluginDef {
|
|
|
308
365
|
|
|
309
366
|
export function definePlugin(def: PluginDef): PluginDef;
|
|
310
367
|
|
|
368
|
+
/** What a command handler receives. */
|
|
369
|
+
export interface CommandContext {
|
|
370
|
+
/** The originating chat message. */
|
|
371
|
+
msg: ChatMessage;
|
|
372
|
+
/** The sender (same as `msg.user`). */
|
|
373
|
+
user?: ChatUser;
|
|
374
|
+
/** The canonical command name that matched (not the alias used). */
|
|
375
|
+
command: string;
|
|
376
|
+
/** Whitespace-split arguments after the command word. */
|
|
377
|
+
args: string[];
|
|
378
|
+
/** The raw argument string (everything after the command word, trimmed). */
|
|
379
|
+
argString: string;
|
|
380
|
+
/** Post a public reply as the plugin's chat bot. */
|
|
381
|
+
reply(text: string): void;
|
|
382
|
+
/** Whisper a reply to the sender; falls back to a public post if their
|
|
383
|
+
* connection is unknown. */
|
|
384
|
+
replyPrivately(text: string): void;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** One command in a {@link defineCommands} table. */
|
|
388
|
+
export interface CommandDefinition {
|
|
389
|
+
/** Alternate names that invoke this command. */
|
|
390
|
+
aliases?: string[];
|
|
391
|
+
/** Only allow senders whose scopes include "MODERATOR". */
|
|
392
|
+
modOnly?: boolean;
|
|
393
|
+
/** Minimum milliseconds between invocations per user (clocked off
|
|
394
|
+
* `msg.timestamp`). */
|
|
395
|
+
cooldownMs?: number;
|
|
396
|
+
/** Invoked when the command runs. */
|
|
397
|
+
run(ctx: CommandContext): void;
|
|
398
|
+
/** Invoked instead of `run` when a non-moderator calls a `modOnly` command. */
|
|
399
|
+
onDenied?(ctx: CommandContext): void;
|
|
400
|
+
/** Invoked instead of `run` when the per-user cooldown hasn't elapsed. */
|
|
401
|
+
onCooldown?(ctx: CommandContext): void;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export interface CommandsConfig {
|
|
405
|
+
/** Command prefix. Default `"!"`. */
|
|
406
|
+
prefix?: string;
|
|
407
|
+
/** Match command names case-sensitively. Default false. */
|
|
408
|
+
caseSensitive?: boolean;
|
|
409
|
+
commands: Record<string, CommandDefinition>;
|
|
410
|
+
/** Fallback when a prefixed message matches no command. */
|
|
411
|
+
onUnknown?(ctx: CommandContext): void;
|
|
412
|
+
/** Default denied/cooldown handlers, used when a command omits its own. */
|
|
413
|
+
onDenied?(ctx: CommandContext): void;
|
|
414
|
+
onCooldown?(ctx: CommandContext): void;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Build a chat-command router (prefix parsing, aliases, per-user cooldowns,
|
|
418
|
+
* moderator gating). Feed the returned function a `ChatMessage`; it returns
|
|
419
|
+
* true when the message was a command (even if gated), false otherwise. */
|
|
420
|
+
export function defineCommands(
|
|
421
|
+
config: CommandsConfig,
|
|
422
|
+
): (msg: ChatMessage) => boolean;
|
|
423
|
+
|
|
311
424
|
/** Typed wrappers around the Owncast host. Each method throws if the
|
|
312
425
|
* corresponding permission was not declared in plugin.manifest.json. */
|
|
313
426
|
export const owncast: {
|
|
@@ -321,8 +434,14 @@ export const owncast: {
|
|
|
321
434
|
* responsible for escaping any untrusted content. Same `chat.send`
|
|
322
435
|
* permission as the other send variants. */
|
|
323
436
|
system(body: string): void;
|
|
324
|
-
/** Private message to one chat client. */
|
|
437
|
+
/** Private message to one chat client. Requires `chat.send`. */
|
|
325
438
|
sendTo(clientId: number | bigint, text: string): void;
|
|
439
|
+
/** Whisper a reply back to whoever sent a chat message. Pass the
|
|
440
|
+
* `ChatMessage` from `onChatMessage`/`filterChatMessage` (or a bare
|
|
441
|
+
* clientId). Returns `false` if the sender's connection is unknown (no
|
|
442
|
+
* clientId), so callers can fall back to a public `send`. Requires
|
|
443
|
+
* `chat.send`. */
|
|
444
|
+
replyTo(msg: ChatMessage | number | bigint, text: string): boolean;
|
|
326
445
|
/** Recent chat history (most recent last). Requires `chat.history`.
|
|
327
446
|
* Default limit is 50; pass a smaller number to get fewer. */
|
|
328
447
|
history(limit?: number): ChatMessage[];
|
|
@@ -349,6 +468,23 @@ export const owncast: {
|
|
|
349
468
|
storage: {
|
|
350
469
|
upload(name: string, data: Uint8Array | string): UploadResult | null;
|
|
351
470
|
};
|
|
471
|
+
/** Private, sandboxed filesystem under data/plugin-data/<slug>/. The bytes
|
|
472
|
+
* stay server-side (never served over HTTP) and the host confines every
|
|
473
|
+
* path to this plugin's own directory. All methods require `storage.fs`. */
|
|
474
|
+
fs: {
|
|
475
|
+
/** Read a file's raw bytes, or null if it doesn't exist. */
|
|
476
|
+
read(path: string): Uint8Array | null;
|
|
477
|
+
/** Read a file as UTF-8 text, or null if it doesn't exist. */
|
|
478
|
+
readText(path: string): string | null;
|
|
479
|
+
/** Write bytes or a string, creating parent directories as needed. */
|
|
480
|
+
write(path: string, data: Uint8Array | string): FsResult;
|
|
481
|
+
/** List entry names directly inside dir; missing dir lists as empty. */
|
|
482
|
+
list(dir: string): string[];
|
|
483
|
+
/** Remove a single file or empty directory. */
|
|
484
|
+
delete(path: string): FsResult;
|
|
485
|
+
/** Report whether a path exists inside the sandbox. */
|
|
486
|
+
exists(path: string): boolean;
|
|
487
|
+
};
|
|
352
488
|
/** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
|
|
353
489
|
* which is high-trust (posts go out under the streamer's own handle);
|
|
354
490
|
* admins should grant it sparingly. */
|
|
@@ -372,6 +508,20 @@ export const owncast: {
|
|
|
372
508
|
kv: {
|
|
373
509
|
get(key: string): string | null;
|
|
374
510
|
set(key: string, value: string | number): void;
|
|
511
|
+
/** Read a JSON value, parsed. Returns `fallback` (default `undefined`)
|
|
512
|
+
* when the key is unset or holds invalid JSON. Requires `storage.kv`. */
|
|
513
|
+
getJSON<T = unknown>(key: string, fallback?: T): T;
|
|
514
|
+
/** Store a value as JSON. Requires `storage.kv`. */
|
|
515
|
+
setJSON(key: string, value: unknown): void;
|
|
516
|
+
};
|
|
517
|
+
/** Read this plugin's admin-configurable settings, declared under
|
|
518
|
+
* `config` in the manifest. Ambient — no permission required. */
|
|
519
|
+
config: {
|
|
520
|
+
/** The effective value of a manifest-declared config key (admin override,
|
|
521
|
+
* else the declared default), parsed to its declared type. Returns
|
|
522
|
+
* `fallback` (default `undefined`) for an unknown key or one with no
|
|
523
|
+
* value. */
|
|
524
|
+
get<T = unknown>(key: string, fallback?: T): T;
|
|
375
525
|
};
|
|
376
526
|
events: {
|
|
377
527
|
emit(eventType: string, payload: unknown): void;
|
|
@@ -401,6 +551,20 @@ export const owncast: {
|
|
|
401
551
|
* `http.sse` permission. */
|
|
402
552
|
send(channel: string, event: string, data: unknown): void;
|
|
403
553
|
};
|
|
554
|
+
/** Host-driven timers. The sandbox has no setTimeout; these ask the host to
|
|
555
|
+
* call your callback back later (in this instance). No permission required.
|
|
556
|
+
* Timers do not survive a plugin reload or host restart. */
|
|
557
|
+
timer: {
|
|
558
|
+
/** Run `fn` once after ~`ms` milliseconds. Returns an id for `clear()`.
|
|
559
|
+
* Very small delays are clamped up by the host; throws past the
|
|
560
|
+
* per-plugin pending-timer cap. */
|
|
561
|
+
setTimeout(fn: () => void, ms: number): number;
|
|
562
|
+
/** Run `fn` every ~`ms` milliseconds until `clear()`. The next run is
|
|
563
|
+
* scheduled only after the previous one returns. Returns an id. */
|
|
564
|
+
setInterval(fn: () => void, ms: number): number;
|
|
565
|
+
/** Cancel a pending timeout or interval by its id. */
|
|
566
|
+
clear(id: number): void;
|
|
567
|
+
};
|
|
404
568
|
http: {
|
|
405
569
|
fetch(url: string, opts?: HttpRequestOpts): HttpResponse;
|
|
406
570
|
};
|
|
@@ -413,6 +577,8 @@ export const owncast: {
|
|
|
413
577
|
server: {
|
|
414
578
|
info(): ServerInfo;
|
|
415
579
|
socials(): SocialHandle[];
|
|
580
|
+
/** Custom chat emotes (`:code:` → image URL) configured on this server. */
|
|
581
|
+
emotes(): Emote[];
|
|
416
582
|
federation(): FederationInfo;
|
|
417
583
|
tags(): string[];
|
|
418
584
|
};
|
package/index.js
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
let registered = null;
|
|
9
9
|
|
|
10
|
+
// Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
|
|
11
|
+
// the host to schedule a callback and call back via the internal "timer.fire"
|
|
12
|
+
// event. The author's callback stays here in the long-lived instance, keyed by
|
|
13
|
+
// a guest-allocated id the host echoes back. State persists across calls
|
|
14
|
+
// because the plugin instance is reused; timers are dropped on reload.
|
|
15
|
+
let nextTimerId = 1;
|
|
16
|
+
const timerCallbacks = new Map(); // id -> { fn, repeat }
|
|
17
|
+
|
|
10
18
|
const FilterAction = Object.freeze({
|
|
11
19
|
Pass: "pass",
|
|
12
20
|
Modify: "modify",
|
|
@@ -24,6 +32,11 @@ const Events = Object.freeze({
|
|
|
24
32
|
StreamStarted: "stream.started",
|
|
25
33
|
StreamStopped: "stream.stopped",
|
|
26
34
|
StreamTitleChanged: "stream.title.changed",
|
|
35
|
+
// SSE connection lifecycle (who connected to / left a plugin's stream)
|
|
36
|
+
SseConnect: "sse.connect",
|
|
37
|
+
SseDisconnect: "sse.disconnect",
|
|
38
|
+
// Once-a-second tick for periodic work (opt in by defining onTick)
|
|
39
|
+
Tick: "tick",
|
|
27
40
|
// Fediverse, engagement (metadata only) + inbound posts (with content)
|
|
28
41
|
FediverseFollow: "fediverse.follow",
|
|
29
42
|
FediverseLike: "fediverse.like",
|
|
@@ -39,6 +52,7 @@ const Permissions = Object.freeze({
|
|
|
39
52
|
ChatFilter: "chat.filter",
|
|
40
53
|
StorageKV: "storage.kv",
|
|
41
54
|
StorageUpload: "storage.upload",
|
|
55
|
+
StorageFS: "storage.fs",
|
|
42
56
|
EventsEmit: "events.emit",
|
|
43
57
|
NetworkFetch: "network.fetch",
|
|
44
58
|
HttpServe: "http.serve",
|
|
@@ -102,6 +116,11 @@ const HANDLERS = Object.freeze({
|
|
|
102
116
|
event: Events.StreamTitleChanged,
|
|
103
117
|
kind: HandlerKind.Notify,
|
|
104
118
|
},
|
|
119
|
+
// SSE connection lifecycle
|
|
120
|
+
onSseConnect: { event: Events.SseConnect, kind: HandlerKind.Notify },
|
|
121
|
+
onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
|
|
122
|
+
// Once-a-second tick
|
|
123
|
+
onTick: { event: Events.Tick, kind: HandlerKind.Notify },
|
|
105
124
|
// Fediverse engagement (actor + target metadata)
|
|
106
125
|
onFediverseFollow: {
|
|
107
126
|
event: Events.FediverseFollow,
|
|
@@ -134,6 +153,111 @@ function definePlugin(def) {
|
|
|
134
153
|
return def;
|
|
135
154
|
}
|
|
136
155
|
|
|
156
|
+
// defineCommands builds a chat-command router so plugins stop reimplementing
|
|
157
|
+
// prefix parsing, aliases, per-user cooldowns, and moderator gating. It returns
|
|
158
|
+
// a function you feed a ChatMessage (from onChatMessage or filterChatMessage);
|
|
159
|
+
// it parses the command and invokes the matching handler's run(ctx). The return
|
|
160
|
+
// value is true when the message was a command (even if gated), false when it
|
|
161
|
+
// wasn't — so a filter can drop command messages from chat:
|
|
162
|
+
//
|
|
163
|
+
// const commands = defineCommands({
|
|
164
|
+
// prefix: "!",
|
|
165
|
+
// commands: {
|
|
166
|
+
// uptime: { run: (ctx) => ctx.reply("up!") },
|
|
167
|
+
// ban: { modOnly: true, cooldownMs: 5000, run: (ctx) => ctx.reply(`bye ${ctx.args[0]}`) },
|
|
168
|
+
// },
|
|
169
|
+
// });
|
|
170
|
+
// module.exports = definePlugin({
|
|
171
|
+
// onChatMessage: commands,
|
|
172
|
+
// // or, to hide command messages from chat:
|
|
173
|
+
// // filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()),
|
|
174
|
+
// });
|
|
175
|
+
//
|
|
176
|
+
// run(ctx) receives { msg, user, command, args, argString, reply, replyPrivately }.
|
|
177
|
+
// reply posts publicly; replyPrivately whispers to the sender (falling back to a
|
|
178
|
+
// public post if their connection is unknown). Optional hooks: per-command or
|
|
179
|
+
// top-level onCooldown(ctx) / onDenied(ctx), and a top-level onUnknown(ctx).
|
|
180
|
+
function defineCommands(config) {
|
|
181
|
+
config = config || {};
|
|
182
|
+
const prefix = config.prefix || "!";
|
|
183
|
+
const caseSensitive = !!config.caseSensitive;
|
|
184
|
+
const norm = (s) => (caseSensitive ? s : s.toLowerCase());
|
|
185
|
+
|
|
186
|
+
// Resolve every name and alias to its canonical command definition.
|
|
187
|
+
const table = new Map();
|
|
188
|
+
const defs = config.commands || {};
|
|
189
|
+
for (const name of Object.keys(defs)) {
|
|
190
|
+
const def = defs[name];
|
|
191
|
+
table.set(norm(name), { name, def });
|
|
192
|
+
for (const alias of def.aliases || []) table.set(norm(alias), { name, def });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Per-(command,user) cooldown clock, in memory for the plugin's lifetime.
|
|
196
|
+
const lastRun = new Map();
|
|
197
|
+
|
|
198
|
+
return function handle(msg) {
|
|
199
|
+
const body = (msg && msg.body) || "";
|
|
200
|
+
if (!body.startsWith(prefix)) return false;
|
|
201
|
+
const rest = body.slice(prefix.length);
|
|
202
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
203
|
+
if (parts.length === 0) return false;
|
|
204
|
+
const called = parts[0];
|
|
205
|
+
const args = parts.slice(1);
|
|
206
|
+
const argString = rest.slice(called.length).trim();
|
|
207
|
+
|
|
208
|
+
const ctx = {
|
|
209
|
+
msg,
|
|
210
|
+
user: msg && msg.user,
|
|
211
|
+
command: norm(called),
|
|
212
|
+
args,
|
|
213
|
+
argString,
|
|
214
|
+
reply: (text) => owncast.chat.send(text),
|
|
215
|
+
replyPrivately: (text) => {
|
|
216
|
+
if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const entry = table.get(norm(called));
|
|
221
|
+
if (!entry) {
|
|
222
|
+
if (isFn(config.onUnknown)) config.onUnknown(ctx);
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const { name, def } = entry;
|
|
226
|
+
ctx.command = name;
|
|
227
|
+
|
|
228
|
+
// Moderator gating: the sender's scopes must include MODERATOR.
|
|
229
|
+
if (def.modOnly) {
|
|
230
|
+
const scopes = (msg.user && msg.user.scopes) || [];
|
|
231
|
+
if (!scopes.includes("MODERATOR")) {
|
|
232
|
+
if (isFn(def.onDenied)) def.onDenied(ctx);
|
|
233
|
+
else if (isFn(config.onDenied)) config.onDenied(ctx);
|
|
234
|
+
return true; // matched a command, but the caller wasn't allowed
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Per-user cooldown, clocked off msg.timestamp so it's deterministic in
|
|
239
|
+
// tests and independent of any sandbox clock quirks.
|
|
240
|
+
const cooldownMs = def.cooldownMs || 0;
|
|
241
|
+
if (cooldownMs > 0) {
|
|
242
|
+
const userId =
|
|
243
|
+
(msg.user && msg.user.id) ||
|
|
244
|
+
(msg.clientId != null ? `c${msg.clientId}` : "anon");
|
|
245
|
+
const key = `${name}${userId}`;
|
|
246
|
+
const now = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
|
|
247
|
+
const prev = lastRun.get(key);
|
|
248
|
+
if (now && prev && now - prev < cooldownMs) {
|
|
249
|
+
if (isFn(def.onCooldown)) def.onCooldown(ctx);
|
|
250
|
+
else if (isFn(config.onCooldown)) config.onCooldown(ctx);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (now) lastRun.set(key, now);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (isFn(def.run)) def.run(ctx);
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
137
261
|
// Used by the build-generated entry to compute subscriptions for register().
|
|
138
262
|
// Filters can optionally declare a priority via definePlugin({filterPriority}),
|
|
139
263
|
// applied to every filter subscription this plugin owns. Lower = earlier.
|
|
@@ -163,8 +287,20 @@ function describeSubscriptions() {
|
|
|
163
287
|
}
|
|
164
288
|
|
|
165
289
|
function dispatchEvent(envelope) {
|
|
166
|
-
if (!registered) return;
|
|
167
290
|
const { eventType, payload } = envelope;
|
|
291
|
+
// Internal: a host-scheduled timer elapsed. Run the author's callback,
|
|
292
|
+
// dropping one-shot entries first so a throw still cleans up. Not routed to
|
|
293
|
+
// user handlers or the `on` map.
|
|
294
|
+
if (eventType === "timer.fire") {
|
|
295
|
+
const id = payload && payload.id;
|
|
296
|
+
const entry = timerCallbacks.get(id);
|
|
297
|
+
if (entry) {
|
|
298
|
+
if (!entry.repeat) timerCallbacks.delete(id);
|
|
299
|
+
entry.fn();
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (!registered) return;
|
|
168
304
|
for (const [method, info] of Object.entries(HANDLERS)) {
|
|
169
305
|
if (
|
|
170
306
|
info.kind === HandlerKind.Notify &&
|
|
@@ -221,6 +357,27 @@ function permError(apiName, perm) {
|
|
|
221
357
|
return new Error(msg);
|
|
222
358
|
}
|
|
223
359
|
|
|
360
|
+
// scheduleTimer registers a callback and asks the host to schedule it. The id
|
|
361
|
+
// is guest-allocated and echoed back on "timer.fire". Throws if the host
|
|
362
|
+
// rejects the schedule (per-plugin pending-timer cap).
|
|
363
|
+
function scheduleTimer(fn, ms, repeat) {
|
|
364
|
+
if (typeof fn !== "function") {
|
|
365
|
+
throw new Error("owncast.timer: callback must be a function");
|
|
366
|
+
}
|
|
367
|
+
const id = nextTimerId++;
|
|
368
|
+
const delay = Math.max(0, Math.floor(Number(ms) || 0));
|
|
369
|
+
const fns = Host.getFunctions();
|
|
370
|
+
if (!fns.owncast_timer_set) {
|
|
371
|
+
throw new Error("owncast.timer is unavailable in this host");
|
|
372
|
+
}
|
|
373
|
+
const ok = fns.owncast_timer_set(BigInt(id), BigInt(delay), repeat ? 1 : 0);
|
|
374
|
+
if (ok !== 1) {
|
|
375
|
+
throw new Error("owncast.timer: too many pending timers");
|
|
376
|
+
}
|
|
377
|
+
timerCallbacks.set(id, { fn, repeat });
|
|
378
|
+
return id;
|
|
379
|
+
}
|
|
380
|
+
|
|
224
381
|
const owncast = {
|
|
225
382
|
chat: {
|
|
226
383
|
send(text) {
|
|
@@ -270,6 +427,20 @@ const owncast = {
|
|
|
270
427
|
Memory.fromString(text).offset,
|
|
271
428
|
);
|
|
272
429
|
},
|
|
430
|
+
// replyTo whispers text back to whoever sent a chat message. Pass the
|
|
431
|
+
// ChatMessage from onChatMessage/filterChatMessage (or a bare clientId).
|
|
432
|
+
// Returns true if the sender's connection was known and the reply sent,
|
|
433
|
+
// false otherwise (e.g. the message carried no clientId) — letting callers
|
|
434
|
+
// fall back to a public post.
|
|
435
|
+
replyTo(msgOrClientId, text) {
|
|
436
|
+
const clientId =
|
|
437
|
+
msgOrClientId && typeof msgOrClientId === "object"
|
|
438
|
+
? msgOrClientId.clientId
|
|
439
|
+
: msgOrClientId;
|
|
440
|
+
if (clientId === undefined || clientId === null) return false;
|
|
441
|
+
this.sendTo(clientId, text);
|
|
442
|
+
return true;
|
|
443
|
+
},
|
|
273
444
|
clients() {
|
|
274
445
|
const fns = Host.getFunctions();
|
|
275
446
|
if (!fns.owncast_chat_clients)
|
|
@@ -341,6 +512,80 @@ const owncast = {
|
|
|
341
512
|
return JSON.parse(Memory.find(offset).readString());
|
|
342
513
|
},
|
|
343
514
|
},
|
|
515
|
+
// Private, sandboxed filesystem under data/plugin-data/<slug>/. Unlike
|
|
516
|
+
// storage.upload (which publishes browser-accessible files), these bytes
|
|
517
|
+
// stay server-side. The host confines every path to this plugin's own
|
|
518
|
+
// directory. All methods require the 'storage.fs' permission.
|
|
519
|
+
fs: {
|
|
520
|
+
// Read a file's raw bytes. Returns a Uint8Array, or null if the file
|
|
521
|
+
// doesn't exist (or can't be read).
|
|
522
|
+
read(path) {
|
|
523
|
+
const fns = Host.getFunctions();
|
|
524
|
+
if (!fns.owncast_fs_read)
|
|
525
|
+
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
526
|
+
const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
|
|
527
|
+
if (offset == 0) return null;
|
|
528
|
+
return new Uint8Array(Memory.find(offset).readBytes());
|
|
529
|
+
},
|
|
530
|
+
// Read a file as UTF-8 text. Returns a string, or null if the file
|
|
531
|
+
// doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
|
|
532
|
+
readText(path) {
|
|
533
|
+
const fns = Host.getFunctions();
|
|
534
|
+
if (!fns.owncast_fs_read)
|
|
535
|
+
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
536
|
+
const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
|
|
537
|
+
if (offset == 0) return null;
|
|
538
|
+
return Memory.find(offset).readString();
|
|
539
|
+
},
|
|
540
|
+
// Write bytes (Uint8Array) or a string to a file, creating parent
|
|
541
|
+
// directories as needed. Returns { ok, error? }.
|
|
542
|
+
write(path, data) {
|
|
543
|
+
const fns = Host.getFunctions();
|
|
544
|
+
if (!fns.owncast_fs_write)
|
|
545
|
+
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
546
|
+
const dataMem =
|
|
547
|
+
data instanceof Uint8Array
|
|
548
|
+
? Memory.fromBuffer(
|
|
549
|
+
data.buffer.slice(
|
|
550
|
+
data.byteOffset,
|
|
551
|
+
data.byteOffset + data.byteLength,
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
: Memory.fromString(String(data));
|
|
555
|
+
const offset = fns.owncast_fs_write(
|
|
556
|
+
Memory.fromString(path).offset,
|
|
557
|
+
dataMem.offset,
|
|
558
|
+
);
|
|
559
|
+
if (offset == 0) return { ok: false, error: "write failed" };
|
|
560
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
561
|
+
},
|
|
562
|
+
// List the entry names (files and subdirectories) directly inside dir.
|
|
563
|
+
// A missing directory lists as empty. Returns string[].
|
|
564
|
+
list(dir) {
|
|
565
|
+
const fns = Host.getFunctions();
|
|
566
|
+
if (!fns.owncast_fs_list)
|
|
567
|
+
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
568
|
+
const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
|
|
569
|
+
if (offset == 0) return [];
|
|
570
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
571
|
+
},
|
|
572
|
+
// Remove a single file or empty directory. Returns { ok, error? }.
|
|
573
|
+
delete(path) {
|
|
574
|
+
const fns = Host.getFunctions();
|
|
575
|
+
if (!fns.owncast_fs_delete)
|
|
576
|
+
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
577
|
+
const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
|
|
578
|
+
if (offset == 0) return { ok: false, error: "delete failed" };
|
|
579
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
580
|
+
},
|
|
581
|
+
// Report whether a path exists inside the sandbox. Returns boolean.
|
|
582
|
+
exists(path) {
|
|
583
|
+
const fns = Host.getFunctions();
|
|
584
|
+
if (!fns.owncast_fs_exists)
|
|
585
|
+
throw new Error(`permission '${Permissions.StorageFS}' not granted`);
|
|
586
|
+
return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
|
|
587
|
+
},
|
|
588
|
+
},
|
|
344
589
|
fediverse: {
|
|
345
590
|
/** Publish a public text-only post to the fediverse on the streamer's
|
|
346
591
|
* behalf. Returns { url } on success, null on failure (rate-limited,
|
|
@@ -422,6 +667,14 @@ const owncast = {
|
|
|
422
667
|
if (offset == 0) return [];
|
|
423
668
|
return JSON.parse(Memory.find(offset).readString());
|
|
424
669
|
},
|
|
670
|
+
emotes() {
|
|
671
|
+
const fns = Host.getFunctions();
|
|
672
|
+
if (!fns.owncast_server_emotes)
|
|
673
|
+
throw new Error(`permission '${Permissions.ServerRead}' not granted`);
|
|
674
|
+
const offset = fns.owncast_server_emotes();
|
|
675
|
+
if (offset == 0) return [];
|
|
676
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
677
|
+
},
|
|
425
678
|
federation() {
|
|
426
679
|
const fns = Host.getFunctions();
|
|
427
680
|
if (!fns.owncast_server_federation)
|
|
@@ -488,6 +741,34 @@ const owncast = {
|
|
|
488
741
|
Memory.fromString(String(value)).offset,
|
|
489
742
|
);
|
|
490
743
|
},
|
|
744
|
+
// getJSON/setJSON are convenience wrappers over the string-only store, so
|
|
745
|
+
// plugins don't reimplement JSON.parse/stringify for every stored object.
|
|
746
|
+
// getJSON returns `fallback` (default undefined) when the key is unset or
|
|
747
|
+
// holds invalid JSON.
|
|
748
|
+
getJSON(key, fallback) {
|
|
749
|
+
const raw = this.get(key);
|
|
750
|
+
if (raw == null) return fallback;
|
|
751
|
+
try {
|
|
752
|
+
return JSON.parse(raw);
|
|
753
|
+
} catch (_e) {
|
|
754
|
+
return fallback;
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
setJSON(key, value) {
|
|
758
|
+
this.set(key, JSON.stringify(value));
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
config: {
|
|
762
|
+
// get returns the effective value of a manifest-declared config key (the
|
|
763
|
+
// admin-set override, else the declared default), already parsed to its
|
|
764
|
+
// declared type. Returns `fallback` (default undefined) for an unknown key
|
|
765
|
+
// or one with no value. Ambient — no permission required.
|
|
766
|
+
get(key, fallback) {
|
|
767
|
+
const fns = Host.getFunctions();
|
|
768
|
+
const offset = fns.owncast_config_get(Memory.fromString(key).offset);
|
|
769
|
+
if (offset == 0) return fallback;
|
|
770
|
+
return JSON.parse(Memory.find(offset).readString());
|
|
771
|
+
},
|
|
491
772
|
},
|
|
492
773
|
events: {
|
|
493
774
|
emit(eventType, payload) {
|
|
@@ -546,6 +827,26 @@ const owncast = {
|
|
|
546
827
|
);
|
|
547
828
|
},
|
|
548
829
|
},
|
|
830
|
+
timer: {
|
|
831
|
+
// setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
|
|
832
|
+
// repeats until clear(id). The host drives the schedule (the sandbox has
|
|
833
|
+
// no setTimeout); your callback runs in this instance when it fires.
|
|
834
|
+
// Returns an id for clear(). Very small delays are clamped up by the host,
|
|
835
|
+
// and there's a per-plugin cap on pending timers (throws past it).
|
|
836
|
+
// Note: timers are in-memory and do not survive a plugin reload or a host
|
|
837
|
+
// restart. No permission required.
|
|
838
|
+
setTimeout(fn, ms) {
|
|
839
|
+
return scheduleTimer(fn, ms, false);
|
|
840
|
+
},
|
|
841
|
+
setInterval(fn, ms) {
|
|
842
|
+
return scheduleTimer(fn, ms, true);
|
|
843
|
+
},
|
|
844
|
+
clear(id) {
|
|
845
|
+
timerCallbacks.delete(id);
|
|
846
|
+
const fns = Host.getFunctions();
|
|
847
|
+
if (fns.owncast_timer_clear) fns.owncast_timer_clear(BigInt(id));
|
|
848
|
+
},
|
|
849
|
+
},
|
|
549
850
|
http: {
|
|
550
851
|
// fetch(url, opts) → { status, headers, body }
|
|
551
852
|
// Wraps Extism's built-in Http.request. Throws if the manifest didn't
|
|
@@ -571,6 +872,7 @@ const owncast = {
|
|
|
571
872
|
|
|
572
873
|
module.exports = {
|
|
573
874
|
definePlugin,
|
|
875
|
+
defineCommands,
|
|
574
876
|
owncast,
|
|
575
877
|
filter,
|
|
576
878
|
FilterAction,
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -19,11 +19,24 @@ const { execFileSync } = require("child_process");
|
|
|
19
19
|
|
|
20
20
|
const EXTISM_JS_VERSION = "v1.6.0";
|
|
21
21
|
const BINARYEN_VERSION = "version_119";
|
|
22
|
-
// Tracks the SDK version that the host binaries were cut for. Usually
|
|
23
|
-
// matches the SDK's own version in package.json.
|
|
24
|
-
const HOST_BINARIES_VERSION = require("../package.json").version;
|
|
25
22
|
const HOST_BINARIES_REPO = "owncast/plugin-sdk";
|
|
26
23
|
|
|
24
|
+
// The host binaries (owncast-plugin-test/serve) are cut once per MINOR release
|
|
25
|
+
// (a vX.Y.0 git tag); patch releases are JS-only fixes that ride on the same
|
|
26
|
+
// runtime. Deriving the download tag straight from the npm version therefore
|
|
27
|
+
// 404s on every patch (e.g. 0.4.1 has no v0.4.1 binaries), which silently
|
|
28
|
+
// broke `npm test`. Zero the patch component so a patch release fetches its
|
|
29
|
+
// minor's binaries. Override with OWNCAST_PLUGIN_HOST_BINARIES_VERSION (with or
|
|
30
|
+
// without a leading "v") if you ever need a specific tag.
|
|
31
|
+
function hostBinariesVersion() {
|
|
32
|
+
const override = process.env.OWNCAST_PLUGIN_HOST_BINARIES_VERSION;
|
|
33
|
+
if (override) return override.replace(/^v/, "");
|
|
34
|
+
const pkg = require("../package.json").version; // e.g. "0.4.1"
|
|
35
|
+
const [major, minor] = pkg.split(".");
|
|
36
|
+
return `${major}.${minor}.0`;
|
|
37
|
+
}
|
|
38
|
+
const HOST_BINARIES_VERSION = hostBinariesVersion();
|
|
39
|
+
|
|
27
40
|
const platform = process.platform;
|
|
28
41
|
const arch = process.arch;
|
|
29
42
|
|
package/testing.js
CHANGED
|
@@ -78,28 +78,40 @@ function findCacheDir() {
|
|
|
78
78
|
* dir that links to your manifest + wasm and contains only the generated
|
|
79
79
|
* scenarios it's running.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
81
|
+
* Sets `process.exitCode` to non-zero if any scenario failed (never resets a
|
|
82
|
+
* previously-failed code), and returns true on success / false on failure
|
|
83
|
+
* WITHOUT exiting the process. That lets one node process run several test
|
|
84
|
+
* files in a row (see runScenarioFiles); the process ends with the right code
|
|
85
|
+
* once the event loop drains.
|
|
82
86
|
*
|
|
83
87
|
* @param {Array<object>} scenarios, scenario objects: { name, given?, events, expect? }
|
|
84
88
|
* @param {object} [opts]
|
|
85
89
|
* @param {string} [opts.cwd], plugin project directory (default: process.cwd())
|
|
90
|
+
* @returns {boolean} true if every scenario passed
|
|
86
91
|
*/
|
|
92
|
+
function fail(code) {
|
|
93
|
+
// Record a non-zero exit code without clobbering an earlier failure or
|
|
94
|
+
// aborting sibling test files. Returns false for `return fail(...)`.
|
|
95
|
+
if (!process.exitCode) process.exitCode = code;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
function runScenarios(scenarios, opts = {}) {
|
|
88
100
|
if (!Array.isArray(scenarios) || scenarios.length === 0) {
|
|
89
101
|
console.error("runScenarios: no scenarios provided");
|
|
90
|
-
|
|
102
|
+
return fail(2);
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
94
106
|
const manifestPath = path.join(cwd, "plugin.manifest.json");
|
|
95
107
|
if (!fs.existsSync(manifestPath)) {
|
|
96
108
|
console.error(`plugin.manifest.json not found in ${cwd}`);
|
|
97
|
-
|
|
109
|
+
return fail(2);
|
|
98
110
|
}
|
|
99
111
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
100
112
|
if (!manifest.name) {
|
|
101
113
|
console.error("manifest.name is required");
|
|
102
|
-
|
|
114
|
+
return fail(2);
|
|
103
115
|
}
|
|
104
116
|
// wasm + symlink filenames key off slug (the identifier), not the
|
|
105
117
|
// display name. Derive the slug here the same way the build CLI
|
|
@@ -109,14 +121,14 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
109
121
|
console.error(
|
|
110
122
|
`could not derive slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
|
|
111
123
|
);
|
|
112
|
-
|
|
124
|
+
return fail(2);
|
|
113
125
|
}
|
|
114
126
|
const wasmPath = path.join(cwd, `${slug}.wasm`);
|
|
115
127
|
if (!fs.existsSync(wasmPath)) {
|
|
116
128
|
console.error(
|
|
117
129
|
`${slug}.wasm not found at ${wasmPath}, run \`owncast-plugin package\` first`,
|
|
118
130
|
);
|
|
119
|
-
|
|
131
|
+
return fail(2);
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
const cache = findCacheDir();
|
|
@@ -126,7 +138,7 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
126
138
|
`owncast-plugin-test not found at ${bin}\n` +
|
|
127
139
|
`Reinstall @owncast/plugin-sdk to fetch the host toolchain (postinstall handles it).`,
|
|
128
140
|
);
|
|
129
|
-
|
|
141
|
+
return fail(2);
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
// Build a temp project dir that links to the wasm + manifest and contains
|
|
@@ -156,11 +168,46 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
156
168
|
try {
|
|
157
169
|
execFileSync(bin, [tmp], { stdio: "inherit", env });
|
|
158
170
|
} catch (e) {
|
|
159
|
-
|
|
171
|
+
return fail(typeof e.status === "number" ? e.status : 1);
|
|
160
172
|
}
|
|
161
173
|
} finally {
|
|
162
174
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
163
175
|
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Discover and run every `__tests__/*.test.js` file in one node process,
|
|
181
|
+
* aggregating their exit status. Lets you organize scenarios across multiple
|
|
182
|
+
* files (by module/feature) without a shell loop spawning a process per file.
|
|
183
|
+
* Each discovered file is expected to call runScenarios() at load time.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} [opts]
|
|
186
|
+
* @param {string} [opts.cwd], plugin project directory (default: process.cwd())
|
|
187
|
+
* @returns {boolean} true if every file's scenarios passed
|
|
188
|
+
*/
|
|
189
|
+
function runScenarioFiles(opts = {}) {
|
|
190
|
+
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
191
|
+
const dir = path.join(cwd, "__tests__");
|
|
192
|
+
if (!fs.existsSync(dir)) {
|
|
193
|
+
console.error(`no __tests__ directory in ${cwd}`);
|
|
194
|
+
return fail(2);
|
|
195
|
+
}
|
|
196
|
+
const files = fs
|
|
197
|
+
.readdirSync(dir)
|
|
198
|
+
.filter((f) => f.endsWith(".test.js"))
|
|
199
|
+
.sort()
|
|
200
|
+
.map((f) => path.join(dir, f));
|
|
201
|
+
if (files.length === 0) {
|
|
202
|
+
console.error(`no *.test.js files in ${dir}`);
|
|
203
|
+
return fail(2);
|
|
204
|
+
}
|
|
205
|
+
for (const f of files) {
|
|
206
|
+
// Each file runs its own runScenarios() at require time, which records a
|
|
207
|
+
// non-zero process.exitCode on failure but no longer aborts the process.
|
|
208
|
+
require(f);
|
|
209
|
+
}
|
|
210
|
+
return !process.exitCode;
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
module.exports = { runScenarios };
|
|
213
|
+
module.exports = { runScenarios, runScenarioFiles };
|