@frockbot/plugin-mobile-notifications 0.0.0 → 0.1.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/frockbot.json +10 -0
- package/package.json +29 -6
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/mobile.test.ts +158 -0
- package/src/mobile.ts +82 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
package/package.json
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-mobile-notifications",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./mobile": "./src/mobile.ts",
|
|
9
|
+
"./manifest": "./src/manifest.ts",
|
|
10
|
+
"./frockbot.json": "./frockbot.json",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"frockbot": {
|
|
14
|
+
"manifest": "./frockbot.json"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "bun test src",
|
|
18
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@frockbot/mobile-core": "0.1.1",
|
|
22
|
+
"cordis": "4.0.0-rc.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@frockbot/plugin-testkit": "0.1.1",
|
|
26
|
+
"@types/bun": "1.4.0",
|
|
27
|
+
"typescript": "^7.0.2"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
6
32
|
"repository": {
|
|
7
33
|
"type": "git",
|
|
8
34
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
35
|
"directory": "packages/plugin-mobile-notifications"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
36
|
}
|
|
14
37
|
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MobileCommandRegistry,
|
|
4
|
+
MobileNotificationCapability,
|
|
5
|
+
type MobileNotificationRequest,
|
|
6
|
+
} from "@frockbot/mobile-core";
|
|
7
|
+
import {
|
|
8
|
+
createPluginHarness,
|
|
9
|
+
verifyPluginPackage,
|
|
10
|
+
} from "@frockbot/plugin-testkit";
|
|
11
|
+
import manifest from "../frockbot.json" with { type: "json" };
|
|
12
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
13
|
+
import mobileNotificationsPlugin, {
|
|
14
|
+
SHOW_NOTIFICATION_COMMAND,
|
|
15
|
+
type ShowNotificationResult,
|
|
16
|
+
} from "./mobile.js";
|
|
17
|
+
|
|
18
|
+
class FakeNotifications extends MobileNotificationCapability {
|
|
19
|
+
requests: MobileNotificationRequest[] = [];
|
|
20
|
+
|
|
21
|
+
show(request: MobileNotificationRequest, signal: AbortSignal): Promise<void> {
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
this.requests.push(request);
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function createHost(): Promise<{
|
|
29
|
+
harness: Awaited<ReturnType<typeof createPluginHarness>>;
|
|
30
|
+
notifications: FakeNotifications;
|
|
31
|
+
}> {
|
|
32
|
+
const harness = await createPluginHarness([
|
|
33
|
+
MobileCommandRegistry,
|
|
34
|
+
FakeNotifications,
|
|
35
|
+
]);
|
|
36
|
+
return {
|
|
37
|
+
harness,
|
|
38
|
+
notifications: harness.root.mobileNotifications as FakeNotifications,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function expectFailure(
|
|
43
|
+
promise: Promise<unknown>,
|
|
44
|
+
message: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
let failure: unknown;
|
|
47
|
+
try {
|
|
48
|
+
await promise;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
failure = error;
|
|
51
|
+
}
|
|
52
|
+
expect(failure).toBeInstanceOf(Error);
|
|
53
|
+
expect(failure instanceof Error ? failure.message : "").toContain(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("mobile notifications plugin", () => {
|
|
57
|
+
test("registers a command backed by the granted capability", async () => {
|
|
58
|
+
const { harness, notifications } = await createHost();
|
|
59
|
+
await harness.mount(mobileNotificationsPlugin);
|
|
60
|
+
|
|
61
|
+
expect(
|
|
62
|
+
await harness.root.mobileCommands.invoke<ShowNotificationResult>(
|
|
63
|
+
SHOW_NOTIFICATION_COMMAND,
|
|
64
|
+
{ title: " Turn complete ", body: " FrockBot is idle " },
|
|
65
|
+
),
|
|
66
|
+
).toEqual({ shown: true });
|
|
67
|
+
expect(notifications.requests).toEqual([
|
|
68
|
+
{
|
|
69
|
+
title: "Turn complete",
|
|
70
|
+
body: "FrockBot is idle",
|
|
71
|
+
urgency: "normal",
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
await harness.dispose();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("forwards a critical urgency request", async () => {
|
|
78
|
+
const { harness, notifications } = await createHost();
|
|
79
|
+
await harness.mount(mobileNotificationsPlugin);
|
|
80
|
+
|
|
81
|
+
await harness.root.mobileCommands.invoke(SHOW_NOTIFICATION_COMMAND, {
|
|
82
|
+
title: "Blocked",
|
|
83
|
+
urgency: "critical",
|
|
84
|
+
});
|
|
85
|
+
expect(notifications.requests).toEqual([
|
|
86
|
+
{ title: "Blocked", body: undefined, urgency: "critical" },
|
|
87
|
+
]);
|
|
88
|
+
await harness.dispose();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("unregisters its command when its fiber is disposed", async () => {
|
|
92
|
+
const { harness } = await createHost();
|
|
93
|
+
const fiber = await harness.mount(mobileNotificationsPlugin);
|
|
94
|
+
|
|
95
|
+
await fiber.dispose();
|
|
96
|
+
|
|
97
|
+
await expectFailure(
|
|
98
|
+
harness.root.mobileCommands.invoke(SHOW_NOTIFICATION_COMMAND, {
|
|
99
|
+
title: "Not delivered",
|
|
100
|
+
}),
|
|
101
|
+
"is unavailable",
|
|
102
|
+
);
|
|
103
|
+
await harness.dispose();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects malformed input before invoking the capability", async () => {
|
|
107
|
+
const { harness, notifications } = await createHost();
|
|
108
|
+
await harness.mount(mobileNotificationsPlugin);
|
|
109
|
+
|
|
110
|
+
await expectFailure(
|
|
111
|
+
harness.root.mobileCommands.invoke(SHOW_NOTIFICATION_COMMAND, {
|
|
112
|
+
title: " ",
|
|
113
|
+
}),
|
|
114
|
+
"notification title is required",
|
|
115
|
+
);
|
|
116
|
+
await expectFailure(
|
|
117
|
+
harness.root.mobileCommands.invoke(SHOW_NOTIFICATION_COMMAND, {
|
|
118
|
+
title: "Turn complete",
|
|
119
|
+
urgency: "loud",
|
|
120
|
+
}),
|
|
121
|
+
'notification urgency must be "normal" or "critical"',
|
|
122
|
+
);
|
|
123
|
+
await expectFailure(
|
|
124
|
+
harness.root.mobileCommands.invoke(SHOW_NOTIFICATION_COMMAND, {
|
|
125
|
+
title: "Turn complete",
|
|
126
|
+
extra: true,
|
|
127
|
+
}),
|
|
128
|
+
"notification input has unknown fields",
|
|
129
|
+
);
|
|
130
|
+
expect(notifications.requests).toEqual([]);
|
|
131
|
+
await harness.dispose();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("propagates the caller signal to the capability", async () => {
|
|
135
|
+
const { harness, notifications } = await createHost();
|
|
136
|
+
await harness.mount(mobileNotificationsPlugin);
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
controller.abort(new Error("cancelled by test"));
|
|
139
|
+
|
|
140
|
+
await expectFailure(
|
|
141
|
+
harness.root.mobileCommands.invoke(
|
|
142
|
+
SHOW_NOTIFICATION_COMMAND,
|
|
143
|
+
{ title: "Turn complete" },
|
|
144
|
+
controller.signal,
|
|
145
|
+
),
|
|
146
|
+
"cancelled by test",
|
|
147
|
+
);
|
|
148
|
+
expect(notifications.requests).toEqual([]);
|
|
149
|
+
await harness.dispose();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("satisfies plugin package conventions", () => {
|
|
153
|
+
expect(verifyPluginPackage({ packageJson, manifest })).toMatchObject({
|
|
154
|
+
name: "@frockbot/plugin-mobile-notifications",
|
|
155
|
+
contributionKinds: ["mobile"],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
package/src/mobile.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MobileCommand,
|
|
3
|
+
MobileNotificationRequest,
|
|
4
|
+
} from "@frockbot/mobile-core";
|
|
5
|
+
import type { Plugin } from "cordis";
|
|
6
|
+
|
|
7
|
+
export const SHOW_NOTIFICATION_COMMAND = "mobile.notifications.show";
|
|
8
|
+
|
|
9
|
+
export interface ShowNotificationInput {
|
|
10
|
+
title: string;
|
|
11
|
+
body?: string;
|
|
12
|
+
urgency?: "normal" | "critical";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ShowNotificationResult {
|
|
16
|
+
shown: true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optionalString(
|
|
20
|
+
record: Record<string, unknown>,
|
|
21
|
+
key: string,
|
|
22
|
+
maxLength: number,
|
|
23
|
+
): string | undefined {
|
|
24
|
+
const value = record[key];
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
27
|
+
const normalized = value.trim();
|
|
28
|
+
if (!normalized) return undefined;
|
|
29
|
+
if (normalized.length > maxLength) {
|
|
30
|
+
throw new Error(`${key} must be at most ${maxLength} characters`);
|
|
31
|
+
}
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decodeShowNotificationInput(
|
|
36
|
+
input: unknown,
|
|
37
|
+
): ShowNotificationInput {
|
|
38
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
39
|
+
throw new Error("notification input must be an object");
|
|
40
|
+
}
|
|
41
|
+
const keys = Reflect.ownKeys(input);
|
|
42
|
+
if (
|
|
43
|
+
keys.some(
|
|
44
|
+
(key) =>
|
|
45
|
+
typeof key !== "string" ||
|
|
46
|
+
!["title", "body", "urgency"].includes(key) ||
|
|
47
|
+
!Object.prototype.propertyIsEnumerable.call(input, key),
|
|
48
|
+
)
|
|
49
|
+
) {
|
|
50
|
+
throw new Error("notification input has unknown fields");
|
|
51
|
+
}
|
|
52
|
+
const record = input as Record<string, unknown>;
|
|
53
|
+
const title = optionalString(record, "title", 200);
|
|
54
|
+
if (!title) throw new Error("notification title is required");
|
|
55
|
+
const body = optionalString(record, "body", 4_096);
|
|
56
|
+
const urgency = record.urgency ?? "normal";
|
|
57
|
+
if (urgency !== "normal" && urgency !== "critical") {
|
|
58
|
+
throw new Error('notification urgency must be "normal" or "critical"');
|
|
59
|
+
}
|
|
60
|
+
return { title, body, urgency };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const mobileNotificationsPlugin: Plugin.Function = (ctx) => {
|
|
64
|
+
const command: MobileCommand<ShowNotificationInput, ShowNotificationResult> =
|
|
65
|
+
{
|
|
66
|
+
id: SHOW_NOTIFICATION_COMMAND,
|
|
67
|
+
decode: decodeShowNotificationInput,
|
|
68
|
+
async execute(input, context): Promise<ShowNotificationResult> {
|
|
69
|
+
const request: MobileNotificationRequest = {
|
|
70
|
+
title: input.title,
|
|
71
|
+
body: input.body,
|
|
72
|
+
urgency: input.urgency ?? "normal",
|
|
73
|
+
};
|
|
74
|
+
await ctx.mobileNotifications.show(request, context.signal);
|
|
75
|
+
return { shown: true };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return ctx.mobileCommands.register(command);
|
|
79
|
+
};
|
|
80
|
+
mobileNotificationsPlugin.inject = ["mobileCommands", "mobileNotifications"];
|
|
81
|
+
|
|
82
|
+
export default mobileNotificationsPlugin;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED