rethocker 0.1.0 → 0.1.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/LICENSE +21 -0
- package/README.md +1 -1
- package/bin/rethocker-native +0 -0
- package/package.json +12 -3
- package/src/actions.ts +1 -2
- package/src/cli.ts +350 -0
- package/src/daemon.ts +2 -4
- package/src/index.test.ts +654 -30
- package/src/parse-key.test.ts +534 -0
- package/src/parse-key.ts +1 -1
- package/src/register-rule.ts +13 -7
- package/src/rethocker.ts +8 -2
- package/src/scripts/debug-keys.ts +0 -5
- package/src/scripts/example.ts +2 -2
- package/src/scripts/log.ts +233 -0
- package/src/types.ts +0 -21
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Main integration test suite for rethocker.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: test the public API by intercepting the IPC commands sent to the
|
|
5
|
+
* native daemon (via a mock `send` function). This lets us verify the full
|
|
6
|
+
* TypeScript pipeline — key parsing, rule compilation, condition building,
|
|
7
|
+
* handle creation — without needing the native binary or Accessibility permission.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
11
|
+
import { TypedEmitter } from "./daemon.ts";
|
|
2
12
|
import { actions, Key, KeyModifier, rethocker } from "./index.ts";
|
|
13
|
+
import { KEY_CODE_MAP } from "./keys.ts";
|
|
14
|
+
import { registerRule } from "./register-rule.ts";
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Capture IPC commands sent by registerRule/rethocker into an array. */
|
|
19
|
+
function makeSend() {
|
|
20
|
+
const commands: Record<string, unknown>[] = [];
|
|
21
|
+
const send = (obj: Record<string, unknown>) => {
|
|
22
|
+
commands.push(obj);
|
|
23
|
+
};
|
|
24
|
+
return { send, commands };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeEmitter() {
|
|
28
|
+
return new TypedEmitter();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Look up a key code, throwing if missing (safe for tests). */
|
|
32
|
+
function kc(name: string): number {
|
|
33
|
+
const code = KEY_CODE_MAP[name];
|
|
34
|
+
if (code === undefined) throw new Error(`Unknown key: ${name}`);
|
|
35
|
+
return code;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Get first command, throwing if none were sent. */
|
|
39
|
+
function firstCmd(
|
|
40
|
+
commands: Record<string, unknown>[],
|
|
41
|
+
): Record<string, unknown> {
|
|
42
|
+
const cmd = commands.at(0);
|
|
43
|
+
if (cmd === undefined) throw new Error("No commands were sent");
|
|
44
|
+
return cmd;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Key constants ────────────────────────────────────────────────────────────
|
|
3
48
|
|
|
4
49
|
test("Key constants are strings", () => {
|
|
5
50
|
expect(Key.capsLock).toBe("capsLock");
|
|
@@ -13,6 +58,8 @@ test("KeyModifier constants are valid modifier strings", () => {
|
|
|
13
58
|
expect(KeyModifier.Cmd).toBe("cmd");
|
|
14
59
|
expect(KeyModifier.Shift).toBe("shift");
|
|
15
60
|
expect(KeyModifier.Alt).toBe("alt");
|
|
61
|
+
expect(KeyModifier.LeftCmd).toBe("leftCmd");
|
|
62
|
+
expect(KeyModifier.RightAlt).toBe("rightAlt");
|
|
16
63
|
});
|
|
17
64
|
|
|
18
65
|
test("Key interpolation produces valid key strings", () => {
|
|
@@ -23,41 +70,618 @@ test("Key interpolation produces valid key strings", () => {
|
|
|
23
70
|
);
|
|
24
71
|
});
|
|
25
72
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
73
|
+
// ─── Rule registration: IPC commands ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("registerRule — remap rules", () => {
|
|
76
|
+
test("single key remap sends add_rule with remap action", () => {
|
|
77
|
+
const { send, commands } = makeSend();
|
|
78
|
+
const emitter = makeEmitter();
|
|
79
|
+
|
|
80
|
+
registerRule(send, emitter, { key: "capsLock", remap: "escape" });
|
|
81
|
+
|
|
82
|
+
expect(commands).toHaveLength(1);
|
|
83
|
+
const cmd = firstCmd(commands);
|
|
84
|
+
expect(cmd.cmd).toBe("add_rule");
|
|
85
|
+
expect((cmd.trigger as { keyCode: number }).keyCode).toBe(kc("capslock"));
|
|
86
|
+
expect((cmd.action as { type: string; keyCode: number }).type).toBe(
|
|
87
|
+
"remap",
|
|
88
|
+
);
|
|
89
|
+
expect((cmd.action as { type: string; keyCode: number }).keyCode).toBe(
|
|
90
|
+
kc("escape"),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("remap to a sequence sends remap_sequence action", () => {
|
|
95
|
+
const { send, commands } = makeSend();
|
|
96
|
+
const emitter = makeEmitter();
|
|
97
|
+
|
|
98
|
+
registerRule(send, emitter, { key: "capsLock", remap: "escape return" });
|
|
99
|
+
|
|
100
|
+
const cmd = firstCmd(commands);
|
|
101
|
+
expect((cmd.action as { type: string }).type).toBe("remap_sequence");
|
|
102
|
+
const steps = (cmd.action as { steps: Array<{ keyCode: number }> }).steps;
|
|
103
|
+
expect(steps).toHaveLength(2);
|
|
104
|
+
expect(steps[0]?.keyCode).toBe(kc("escape"));
|
|
105
|
+
expect(steps[1]?.keyCode).toBe(kc("return"));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("remap with modifier sends correct modifiers on trigger", () => {
|
|
109
|
+
const { send, commands } = makeSend();
|
|
110
|
+
const emitter = makeEmitter();
|
|
111
|
+
|
|
112
|
+
registerRule(send, emitter, { key: "Cmd+A", remap: "Cmd+C" });
|
|
113
|
+
|
|
114
|
+
const cmd = firstCmd(commands);
|
|
115
|
+
const trigger = cmd.trigger as { keyCode: number; modifiers: string[] };
|
|
116
|
+
expect(trigger.keyCode).toBe(kc("a"));
|
|
117
|
+
expect(trigger.modifiers).toContain("cmd");
|
|
118
|
+
const action = cmd.action as { keyCode: number; modifiers: string[] };
|
|
119
|
+
expect(action.keyCode).toBe(kc("c"));
|
|
120
|
+
expect(action.modifiers).toContain("cmd");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("sequence trigger remap sends add_sequence command", () => {
|
|
124
|
+
const { send, commands } = makeSend();
|
|
125
|
+
const emitter = makeEmitter();
|
|
126
|
+
|
|
127
|
+
registerRule(send, emitter, { key: "Cmd+R T", remap: "escape" });
|
|
128
|
+
|
|
129
|
+
expect(commands[0]?.cmd).toBe("add_sequence");
|
|
130
|
+
const steps = (commands[0] as { steps: Array<{ keyCode: number }> }).steps;
|
|
131
|
+
expect(steps).toHaveLength(2);
|
|
132
|
+
expect(steps[0]?.keyCode).toBe(kc("r"));
|
|
133
|
+
expect(steps[1]?.keyCode).toBe(kc("t"));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("registerRule — shell rules", () => {
|
|
138
|
+
test("single key shell rule sends add_rule with run action", () => {
|
|
139
|
+
const { send, commands } = makeSend();
|
|
140
|
+
const emitter = makeEmitter();
|
|
141
|
+
|
|
142
|
+
registerRule(send, emitter, { key: "F1", execute: "open -a Safari" });
|
|
143
|
+
|
|
144
|
+
const cmd = firstCmd(commands);
|
|
145
|
+
expect(cmd.cmd).toBe("add_rule");
|
|
146
|
+
expect((cmd.action as { type: string; command: string }).type).toBe("run");
|
|
147
|
+
expect((cmd.action as { type: string; command: string }).command).toBe(
|
|
148
|
+
"open -a Safari",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("execute array is joined with &&", () => {
|
|
153
|
+
const { send, commands } = makeSend();
|
|
154
|
+
const emitter = makeEmitter();
|
|
155
|
+
|
|
156
|
+
registerRule(send, emitter, {
|
|
157
|
+
key: "F1",
|
|
158
|
+
execute: ["open -a Safari", "open -a Terminal"],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const action = commands[0]?.action as { command: string };
|
|
162
|
+
expect(action.command).toBe("open -a Safari && open -a Terminal");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("sequence key shell rule sends add_sequence with run action", () => {
|
|
166
|
+
const { send, commands } = makeSend();
|
|
167
|
+
const emitter = makeEmitter();
|
|
168
|
+
|
|
169
|
+
registerRule(send, emitter, {
|
|
170
|
+
key: "Ctrl+J Ctrl+K",
|
|
171
|
+
execute: "echo hello",
|
|
172
|
+
consume: true,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const cmd = firstCmd(commands);
|
|
176
|
+
expect(cmd.cmd).toBe("add_sequence");
|
|
177
|
+
expect((cmd.action as { type: string }).type).toBe("run");
|
|
178
|
+
expect(cmd.consume).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("registerRule — handler rules", () => {
|
|
183
|
+
test("single key handler sends add_rule with emit action and fires callback", () => {
|
|
184
|
+
const { send, commands } = makeSend();
|
|
185
|
+
const emitter = makeEmitter();
|
|
186
|
+
const handler = mock((_e: unknown) => {
|
|
187
|
+
/* captured */
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
registerRule(send, emitter, { key: "escape", handler });
|
|
191
|
+
|
|
192
|
+
const cmd = firstCmd(commands);
|
|
193
|
+
expect(cmd.cmd).toBe("add_rule");
|
|
194
|
+
expect((cmd.action as { type: string }).type).toBe("emit");
|
|
195
|
+
|
|
196
|
+
// Simulate native daemon emitting the matched event
|
|
197
|
+
const eventID = (cmd.action as { eventID: string }).eventID;
|
|
198
|
+
const ruleID = cmd.id as string;
|
|
199
|
+
emitter.emit("event", eventID, ruleID);
|
|
200
|
+
|
|
201
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
202
|
+
const callArg = handler.mock.calls[0]?.[0] as
|
|
203
|
+
| { type: string; suppressed: boolean }
|
|
204
|
+
| undefined;
|
|
205
|
+
expect(callArg?.type).toBe("keydown");
|
|
206
|
+
expect(callArg?.suppressed).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("sequence key handler sends add_sequence and fires callback on sequence event", () => {
|
|
210
|
+
const { send, commands } = makeSend();
|
|
211
|
+
const emitter = makeEmitter();
|
|
212
|
+
const handler = mock((_e: unknown) => {
|
|
213
|
+
/* captured */
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
registerRule(send, emitter, { key: "Ctrl+J Ctrl+K", handler });
|
|
217
|
+
|
|
218
|
+
expect(commands[0]?.cmd).toBe("add_sequence");
|
|
219
|
+
const ruleID = commands[0]?.id as string;
|
|
220
|
+
const eventID = (commands[0]?.action as { eventID: string }).eventID;
|
|
221
|
+
|
|
222
|
+
// Simulate sequence_matched from native daemon
|
|
223
|
+
emitter.emit("sequence", ruleID, eventID);
|
|
224
|
+
|
|
225
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ─── App conditions ───────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe("registerRule — app conditions", () => {
|
|
232
|
+
test("app name (no dot) produces name-based activeApp condition", () => {
|
|
233
|
+
const { send, commands } = makeSend();
|
|
234
|
+
const emitter = makeEmitter();
|
|
235
|
+
|
|
236
|
+
registerRule(send, emitter, {
|
|
237
|
+
key: "F1",
|
|
238
|
+
execute: "echo hi",
|
|
239
|
+
app: "Terminal",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const conditions = commands[0]?.conditions as {
|
|
243
|
+
activeApp: Array<{ name?: string; bundleID?: string; invert?: boolean }>;
|
|
244
|
+
};
|
|
245
|
+
expect(conditions.activeApp).toHaveLength(1);
|
|
246
|
+
expect(conditions.activeApp[0]?.name).toBe("Terminal");
|
|
247
|
+
expect(conditions.activeApp[0]?.bundleID).toBeUndefined();
|
|
248
|
+
expect(conditions.activeApp[0]?.invert).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("app bundle ID (contains dot) produces bundleID-based condition", () => {
|
|
252
|
+
const { send, commands } = makeSend();
|
|
253
|
+
const emitter = makeEmitter();
|
|
254
|
+
|
|
255
|
+
registerRule(send, emitter, {
|
|
256
|
+
key: "F1",
|
|
257
|
+
execute: "echo hi",
|
|
258
|
+
app: "com.apple.Terminal",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const conditions = commands[0]?.conditions as {
|
|
262
|
+
activeApp: Array<{ name?: string; bundleID?: string }>;
|
|
263
|
+
};
|
|
264
|
+
expect(conditions.activeApp[0]?.bundleID).toBe("com.apple.Terminal");
|
|
265
|
+
expect(conditions.activeApp[0]?.name).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("! prefix inverts the condition", () => {
|
|
269
|
+
const { send, commands } = makeSend();
|
|
270
|
+
const emitter = makeEmitter();
|
|
271
|
+
|
|
272
|
+
registerRule(send, emitter, {
|
|
273
|
+
key: "F1",
|
|
274
|
+
execute: "echo hi",
|
|
275
|
+
app: "!Terminal",
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const conditions = commands[0]?.conditions as {
|
|
279
|
+
activeApp: Array<{ name?: string; invert?: boolean }>;
|
|
280
|
+
};
|
|
281
|
+
expect(conditions.activeApp[0]?.name).toBe("Terminal");
|
|
282
|
+
expect(conditions.activeApp[0]?.invert).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("! prefix works with bundle IDs too", () => {
|
|
286
|
+
const { send, commands } = makeSend();
|
|
287
|
+
const emitter = makeEmitter();
|
|
288
|
+
|
|
289
|
+
registerRule(send, emitter, {
|
|
290
|
+
key: "F1",
|
|
291
|
+
execute: "echo hi",
|
|
292
|
+
app: "!com.apple.Terminal",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const conditions = commands[0]?.conditions as {
|
|
296
|
+
activeApp: Array<{ bundleID?: string; invert?: boolean }>;
|
|
297
|
+
};
|
|
298
|
+
expect(conditions.activeApp[0]?.bundleID).toBe("com.apple.Terminal");
|
|
299
|
+
expect(conditions.activeApp[0]?.invert).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("app array produces multiple activeApp conditions (OR-ed)", () => {
|
|
303
|
+
const { send, commands } = makeSend();
|
|
304
|
+
const emitter = makeEmitter();
|
|
305
|
+
|
|
306
|
+
registerRule(send, emitter, {
|
|
307
|
+
key: "F1",
|
|
308
|
+
execute: "echo hi",
|
|
309
|
+
app: ["Safari", "Chrome", "Firefox"],
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const conditions = commands[0]?.conditions as {
|
|
313
|
+
activeApp: Array<{ name?: string }>;
|
|
314
|
+
};
|
|
315
|
+
expect(conditions.activeApp).toHaveLength(3);
|
|
316
|
+
expect(conditions.activeApp.map((c) => c.name)).toEqual([
|
|
317
|
+
"Safari",
|
|
318
|
+
"Chrome",
|
|
319
|
+
"Firefox",
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("no app condition sends empty conditions object", () => {
|
|
324
|
+
const { send, commands } = makeSend();
|
|
325
|
+
const emitter = makeEmitter();
|
|
326
|
+
|
|
327
|
+
registerRule(send, emitter, { key: "F1", execute: "echo hi" });
|
|
328
|
+
|
|
329
|
+
expect(commands[0]?.conditions).toEqual({});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── Rule handles ─────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
describe("registerRule — rule handles", () => {
|
|
336
|
+
test("returned handle has id, remove, enable, disable", () => {
|
|
337
|
+
const { send } = makeSend();
|
|
338
|
+
const emitter = makeEmitter();
|
|
339
|
+
|
|
340
|
+
const handle = registerRule(send, emitter, {
|
|
341
|
+
key: "escape",
|
|
342
|
+
execute: "echo hi",
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(typeof handle.id).toBe("string");
|
|
346
|
+
expect(handle.id.length).toBeGreaterThan(0);
|
|
347
|
+
expect(typeof handle.remove).toBe("function");
|
|
348
|
+
expect(typeof handle.enable).toBe("function");
|
|
349
|
+
expect(typeof handle.disable).toBe("function");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("handle.remove() sends remove_rule", () => {
|
|
353
|
+
const { send, commands } = makeSend();
|
|
354
|
+
const emitter = makeEmitter();
|
|
355
|
+
|
|
356
|
+
const handle = registerRule(send, emitter, {
|
|
357
|
+
key: "escape",
|
|
358
|
+
execute: "echo hi",
|
|
359
|
+
});
|
|
360
|
+
const id = handle.id;
|
|
361
|
+
commands.length = 0; // clear the add_rule command
|
|
362
|
+
|
|
363
|
+
handle.remove();
|
|
364
|
+
|
|
365
|
+
expect(commands[0]?.cmd).toBe("remove_rule");
|
|
366
|
+
expect(commands[0]?.id).toBe(id);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("handle.disable() sends set_rule_enabled with enabled:false", () => {
|
|
370
|
+
const { send, commands } = makeSend();
|
|
371
|
+
const emitter = makeEmitter();
|
|
372
|
+
|
|
373
|
+
const handle = registerRule(send, emitter, {
|
|
374
|
+
key: "escape",
|
|
375
|
+
execute: "echo hi",
|
|
376
|
+
});
|
|
377
|
+
commands.length = 0;
|
|
378
|
+
|
|
379
|
+
handle.disable();
|
|
380
|
+
|
|
381
|
+
expect(commands[0]?.cmd).toBe("set_rule_enabled");
|
|
382
|
+
expect(commands[0]?.enabled).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("handle.enable() sends set_rule_enabled with enabled:true", () => {
|
|
386
|
+
const { send, commands } = makeSend();
|
|
387
|
+
const emitter = makeEmitter();
|
|
388
|
+
|
|
389
|
+
const handle = registerRule(send, emitter, {
|
|
390
|
+
key: "escape",
|
|
391
|
+
execute: "echo hi",
|
|
392
|
+
});
|
|
393
|
+
commands.length = 0;
|
|
394
|
+
|
|
395
|
+
handle.enable();
|
|
396
|
+
|
|
397
|
+
expect(commands[0]?.cmd).toBe("set_rule_enabled");
|
|
398
|
+
expect(commands[0]?.enabled).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("sequence handle remove() sends remove_sequence", () => {
|
|
402
|
+
const { send, commands } = makeSend();
|
|
403
|
+
const emitter = makeEmitter();
|
|
404
|
+
|
|
405
|
+
const handle = registerRule(send, emitter, {
|
|
406
|
+
key: "Ctrl+J Ctrl+K",
|
|
407
|
+
execute: "echo hi",
|
|
408
|
+
});
|
|
409
|
+
commands.length = 0;
|
|
410
|
+
|
|
411
|
+
handle.remove();
|
|
412
|
+
|
|
413
|
+
expect(commands[0]?.cmd).toBe("remove_sequence");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("disabled:true sends rule with enabled:false", () => {
|
|
417
|
+
const { send, commands } = makeSend();
|
|
418
|
+
const emitter = makeEmitter();
|
|
419
|
+
|
|
420
|
+
registerRule(send, emitter, {
|
|
421
|
+
key: "F1",
|
|
422
|
+
execute: "echo hi",
|
|
423
|
+
disabled: true,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(commands[0]?.enabled).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("custom id is used for the rule", () => {
|
|
430
|
+
const { send, commands } = makeSend();
|
|
431
|
+
const emitter = makeEmitter();
|
|
432
|
+
|
|
433
|
+
registerRule(send, emitter, {
|
|
434
|
+
key: "F1",
|
|
435
|
+
execute: "echo hi",
|
|
436
|
+
id: "my-custom-id",
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(commands[0]?.id).toBe("my-custom-id");
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ─── rethocker() handle ───────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
describe("rethocker() handle", () => {
|
|
446
|
+
test("creates a handle with expected methods", () => {
|
|
447
|
+
const rk = rethocker();
|
|
448
|
+
expect(typeof rk.add).toBe("function");
|
|
449
|
+
expect(typeof rk.remove).toBe("function");
|
|
450
|
+
expect(typeof rk.enable).toBe("function");
|
|
451
|
+
expect(typeof rk.disable).toBe("function");
|
|
452
|
+
expect(typeof rk.on).toBe("function");
|
|
453
|
+
expect(typeof rk.start).toBe("function");
|
|
454
|
+
expect(typeof rk.stop).toBe("function");
|
|
455
|
+
expect(typeof rk.unref).toBe("function");
|
|
456
|
+
expect(typeof rk.execute).toBe("function");
|
|
457
|
+
expect(typeof rk.ready).toBe("boolean");
|
|
458
|
+
rk.stop();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("ready is false before daemon starts", () => {
|
|
462
|
+
const rk = rethocker();
|
|
463
|
+
expect(rk.ready).toBe(false);
|
|
464
|
+
rk.stop();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("add() accepts a single rule without throwing", () => {
|
|
468
|
+
const rk = rethocker();
|
|
469
|
+
expect(() => rk.add({ key: "escape", execute: "echo hi" })).not.toThrow();
|
|
470
|
+
rk.stop();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("add() accepts an array of rules without throwing", () => {
|
|
474
|
+
const rk = rethocker();
|
|
475
|
+
expect(() =>
|
|
476
|
+
rk.add([
|
|
477
|
+
{ key: "F1", execute: "echo one" },
|
|
478
|
+
{ key: "F2", execute: "echo two" },
|
|
479
|
+
]),
|
|
480
|
+
).not.toThrow();
|
|
481
|
+
rk.stop();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("initial rules are registered at construction without throwing", () => {
|
|
485
|
+
const rk = rethocker([
|
|
486
|
+
{ key: "F1", execute: "echo one" },
|
|
487
|
+
{ key: "F2", execute: "echo two" },
|
|
488
|
+
]);
|
|
489
|
+
rk.stop();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("on() returns an unsubscribe function", () => {
|
|
493
|
+
const rk = rethocker();
|
|
494
|
+
const off = rk.on("error", () => {
|
|
495
|
+
/* listener */
|
|
496
|
+
});
|
|
497
|
+
expect(typeof off).toBe("function");
|
|
498
|
+
expect(() => off()).not.toThrow();
|
|
499
|
+
rk.stop();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("execute() with string array joins with && and returns a promise", async () => {
|
|
503
|
+
const rk = rethocker();
|
|
504
|
+
const result = rk.execute(["echo a", "echo b"]);
|
|
505
|
+
expect(result instanceof Promise).toBe(true);
|
|
506
|
+
await result;
|
|
507
|
+
rk.stop();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("execute() with plain string also returns a promise", async () => {
|
|
511
|
+
const rk = rethocker();
|
|
512
|
+
const result = rk.execute("echo hello");
|
|
513
|
+
expect(result instanceof Promise).toBe(true);
|
|
514
|
+
await result;
|
|
515
|
+
rk.stop();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("remove(id) does not throw for a registered rule id", () => {
|
|
519
|
+
const rk = rethocker();
|
|
520
|
+
rk.add({ key: "F1", execute: "echo hi", id: "test-remove-id" });
|
|
521
|
+
expect(() => rk.remove("test-remove-id")).not.toThrow();
|
|
522
|
+
rk.stop();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("remove(id) does not throw for an unknown id", () => {
|
|
526
|
+
const rk = rethocker();
|
|
527
|
+
expect(() => rk.remove("nonexistent")).not.toThrow();
|
|
528
|
+
rk.stop();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("enable(id) enables a specific rule without throwing", () => {
|
|
532
|
+
const rk = rethocker();
|
|
533
|
+
rk.add({ key: "F1", execute: "echo hi", id: "test-enable-id" });
|
|
534
|
+
expect(() => rk.enable("test-enable-id")).not.toThrow();
|
|
535
|
+
rk.stop();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("enable() with no arg enables all rules without throwing", () => {
|
|
539
|
+
const rk = rethocker([
|
|
540
|
+
{ key: "F1", execute: "echo one", id: "rule-a" },
|
|
541
|
+
{ key: "F2", execute: "echo two", id: "rule-b" },
|
|
542
|
+
]);
|
|
543
|
+
expect(() => rk.enable()).not.toThrow();
|
|
544
|
+
rk.stop();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("disable(id) disables a specific rule without throwing", () => {
|
|
548
|
+
const rk = rethocker();
|
|
549
|
+
rk.add({ key: "F1", execute: "echo hi", id: "test-disable-id" });
|
|
550
|
+
expect(() => rk.disable("test-disable-id")).not.toThrow();
|
|
551
|
+
rk.stop();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("disable() with no arg disables all rules without throwing", () => {
|
|
555
|
+
const rk = rethocker([
|
|
556
|
+
{ key: "F1", execute: "echo one", id: "rule-c" },
|
|
557
|
+
{ key: "F2", execute: "echo two", id: "rule-d" },
|
|
558
|
+
]);
|
|
559
|
+
expect(() => rk.disable()).not.toThrow();
|
|
560
|
+
rk.stop();
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// ─── actions ─────────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
describe("actions.window", () => {
|
|
567
|
+
test("all window actions return strings", () => {
|
|
568
|
+
expect(typeof actions.window.halfLeft()).toBe("string");
|
|
569
|
+
expect(typeof actions.window.halfRight()).toBe("string");
|
|
570
|
+
expect(typeof actions.window.halfTop()).toBe("string");
|
|
571
|
+
expect(typeof actions.window.halfBottom()).toBe("string");
|
|
572
|
+
expect(typeof actions.window.thirdLeft()).toBe("string");
|
|
573
|
+
expect(typeof actions.window.thirdCenter()).toBe("string");
|
|
574
|
+
expect(typeof actions.window.thirdRight()).toBe("string");
|
|
575
|
+
expect(typeof actions.window.quarterTopLeft()).toBe("string");
|
|
576
|
+
expect(typeof actions.window.quarterTopRight()).toBe("string");
|
|
577
|
+
expect(typeof actions.window.quarterBottomLeft()).toBe("string");
|
|
578
|
+
expect(typeof actions.window.quarterBottomRight()).toBe("string");
|
|
579
|
+
expect(typeof actions.window.maximize()).toBe("string");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("window actions contain osascript", () => {
|
|
583
|
+
expect(actions.window.halfLeft()).toContain("osascript");
|
|
584
|
+
expect(actions.window.maximize()).toContain("osascript");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("window actions with app name include the app name", () => {
|
|
588
|
+
expect(actions.window.halfLeft("Figma")).toContain("Figma");
|
|
589
|
+
expect(actions.window.maximize("iTerm")).toContain("iTerm");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("without app targets frontmost via System Events", () => {
|
|
593
|
+
expect(actions.window.halfLeft()).toContain("System Events");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("with app uses tell application block", () => {
|
|
597
|
+
expect(actions.window.halfLeft("Figma")).toContain(
|
|
598
|
+
'tell application "Figma"',
|
|
599
|
+
);
|
|
600
|
+
});
|
|
31
601
|
});
|
|
32
602
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
603
|
+
describe("actions.app", () => {
|
|
604
|
+
test("focus by name uses open -a", () => {
|
|
605
|
+
expect(actions.app.focus("Slack")).toContain("open -a");
|
|
606
|
+
expect(actions.app.focus("Slack")).toContain("Slack");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("focus by bundle ID uses open -b", () => {
|
|
610
|
+
expect(actions.app.focus("com.tinyspeck.slackmacgap")).toContain("open -b");
|
|
611
|
+
expect(actions.app.focus("com.tinyspeck.slackmacgap")).toContain(
|
|
612
|
+
"com.tinyspeck.slackmacgap",
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("quit by name uses tell application by name", () => {
|
|
617
|
+
const cmd = actions.app.quit("Slack");
|
|
618
|
+
expect(cmd).toContain("osascript");
|
|
619
|
+
expect(cmd).toContain("Slack");
|
|
620
|
+
expect(cmd).toContain("quit");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("quit by bundle ID uses tell application id", () => {
|
|
624
|
+
const cmd = actions.app.quit("com.tinyspeck.slackmacgap");
|
|
625
|
+
expect(cmd).toContain("application id");
|
|
626
|
+
expect(cmd).toContain("com.tinyspeck.slackmacgap");
|
|
627
|
+
});
|
|
37
628
|
});
|
|
38
629
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
630
|
+
describe("actions.shortcut", () => {
|
|
631
|
+
test("returns shortcuts run command with the name", () => {
|
|
632
|
+
const cmd = actions.shortcut("Morning Routine");
|
|
633
|
+
expect(cmd).toContain("shortcuts run");
|
|
634
|
+
expect(cmd).toContain("Morning Routine");
|
|
635
|
+
});
|
|
43
636
|
});
|
|
44
637
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
638
|
+
describe("actions.media", () => {
|
|
639
|
+
test("all media actions return strings", () => {
|
|
640
|
+
expect(typeof actions.media.playPause()).toBe("string");
|
|
641
|
+
expect(typeof actions.media.next()).toBe("string");
|
|
642
|
+
expect(typeof actions.media.previous()).toBe("string");
|
|
643
|
+
expect(typeof actions.media.mute()).toBe("string");
|
|
644
|
+
expect(typeof actions.media.setVolume(50)).toBe("string");
|
|
645
|
+
expect(typeof actions.media.volumeUp()).toBe("string");
|
|
646
|
+
expect(typeof actions.media.volumeDown()).toBe("string");
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("setVolume clamps to maximum 100", () => {
|
|
650
|
+
expect(actions.media.setVolume(150)).toContain("100");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("setVolume clamps to minimum 0", () => {
|
|
654
|
+
expect(actions.media.setVolume(-10)).toContain("0");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("setVolume passes through values in range", () => {
|
|
658
|
+
expect(actions.media.setVolume(50)).toContain("50");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("volumeUp accepts custom step", () => {
|
|
662
|
+
expect(actions.media.volumeUp(5)).toContain("5");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("volumeDown accepts custom step", () => {
|
|
666
|
+
expect(actions.media.volumeDown(15)).toContain("15");
|
|
667
|
+
});
|
|
48
668
|
});
|
|
49
669
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
670
|
+
describe("actions.system", () => {
|
|
671
|
+
test("all system actions return strings", () => {
|
|
672
|
+
expect(typeof actions.system.sleep()).toBe("string");
|
|
673
|
+
expect(typeof actions.system.lockScreen()).toBe("string");
|
|
674
|
+
expect(typeof actions.system.showDesktop()).toBe("string");
|
|
675
|
+
expect(typeof actions.system.missionControl()).toBe("string");
|
|
676
|
+
expect(typeof actions.system.emptyTrash()).toBe("string");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("sleep uses System Events", () => {
|
|
680
|
+
expect(actions.system.sleep()).toContain("System Events");
|
|
681
|
+
expect(actions.system.sleep()).toContain("sleep");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("lockScreen references CGSession", () => {
|
|
685
|
+
expect(actions.system.lockScreen()).toContain("CGSession");
|
|
686
|
+
});
|
|
63
687
|
});
|