@crewhaus/channel-adapter-imessage 0.1.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-imessage",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "macOS-only iMessage channel adapter: ~/Library/Messages/chat.db polling + osascript send + cursor-based idempotency (Section 33)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Coverage for the production `defaultOsascript` path — the branch that
3
+ * dynamically `import("node:child_process")` and spawns the real `osascript`.
4
+ *
5
+ * Calling `sendReply` without an injected `osascript` runner exercises that
6
+ * default. To keep the test deterministic (no real process, no real iMessage
7
+ * send) we replace `node:child_process` with a fake `spawn` via `mock.module`.
8
+ *
9
+ * This lives in its own file so only these tests see the mock — but Bun does
10
+ * NOT reset module mocks at the file boundary: `bun test` runs every file in
11
+ * one process, file order is nondeterministic, and a `mock.module` persists
12
+ * until overwritten. The `afterAll` below re-mocks the real module so the
13
+ * fake `spawn` cannot leak into files that happen to run later.
14
+ */
15
+ import { afterAll, describe, expect, mock, test } from "bun:test";
16
+ import { EventEmitter } from "node:events";
17
+ import { rmSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+
20
+ import { buildFixtureChatDb } from "./fixtures/build-chat-db";
21
+
22
+ // Captured BEFORE the mock below so afterAll can reinstall the real module.
23
+ const realChildProcess = require("node:child_process") as typeof import("node:child_process");
24
+
25
+ /** Records the scripts handed to the fake `osascript` across the file. */
26
+ const writtenScripts: string[] = [];
27
+ let nextExit = 0;
28
+
29
+ mock.module("node:child_process", () => ({
30
+ spawn: (command: string, args: readonly string[], options: unknown) => {
31
+ // Sanity-check the default runner invokes osascript reading from stdin.
32
+ expect(command).toBe("osascript");
33
+ expect(args).toEqual(["-"]);
34
+ expect(options).toEqual({ stdio: ["pipe", "ignore", "pipe"] });
35
+ const child = new EventEmitter() as EventEmitter & {
36
+ stderr: EventEmitter;
37
+ stdin: { write(s: string): void; end(): void };
38
+ };
39
+ child.stderr = new EventEmitter();
40
+ child.stdin = {
41
+ write: (s: string) => writtenScripts.push(s),
42
+ end: () => {},
43
+ };
44
+ queueMicrotask(() => {
45
+ if (nextExit === 0) {
46
+ child.emit("close", 0);
47
+ } else {
48
+ child.stderr.emit("data", Buffer.from("osascript boom", "utf8"));
49
+ child.emit("close", nextExit);
50
+ }
51
+ });
52
+ return child;
53
+ },
54
+ }));
55
+
56
+ afterAll(() => {
57
+ // Bun has no mock.module restore API; re-mocking with the real exports is
58
+ // the documented way to undo a module mock. Without this, the fake `spawn`
59
+ // (which asserts it is called as `osascript`) stays registered for every
60
+ // test file that runs after this one.
61
+ mock.module("node:child_process", () => realChildProcess);
62
+ });
63
+
64
+ // Import AFTER registering the mock so the dynamic import inside
65
+ // defaultOsascript resolves to the fake.
66
+ const { createIMessageAdapter } = await import("./index");
67
+
68
+ function makeAdapter() {
69
+ const dbPath = buildFixtureChatDb();
70
+ const cursorPath = join(dirname(dbPath), "cursor.json");
71
+ const adapter = createIMessageAdapter({
72
+ chatDbPath: dbPath,
73
+ cursorPath,
74
+ requireHostOptIn: false,
75
+ });
76
+ return { adapter, cleanup: () => rmSync(dirname(dbPath), { recursive: true, force: true }) };
77
+ }
78
+
79
+ const baseEvent = {
80
+ idempotencyKey: "imsg:1",
81
+ workspaceId: "imessage",
82
+ channelId: "alice@example.com",
83
+ userId: "alice@example.com",
84
+ ts: "0",
85
+ text: "in",
86
+ subtype: "message",
87
+ } as const;
88
+
89
+ describe("defaultOsascript (real-runner branch, child_process mocked)", () => {
90
+ test("sendReply with no injected runner spawns osascript and writes the script", async () => {
91
+ nextExit = 0;
92
+ writtenScripts.length = 0;
93
+ const { adapter, cleanup } = makeAdapter();
94
+ try {
95
+ await adapter.sendReply({ event: baseEvent, text: "hello from default" });
96
+ expect(writtenScripts.length).toBe(1);
97
+ expect(writtenScripts[0]).toContain('tell application "Messages"');
98
+ expect(writtenScripts[0]).toContain('send "hello from default"');
99
+ } finally {
100
+ cleanup();
101
+ }
102
+ });
103
+
104
+ test("sendReply rejects when the spawned osascript exits non-zero", async () => {
105
+ nextExit = 3;
106
+ writtenScripts.length = 0;
107
+ const { adapter, cleanup } = makeAdapter();
108
+ try {
109
+ await expect(adapter.sendReply({ event: baseEvent, text: "x" })).rejects.toThrow(
110
+ /osascript exited with 3: osascript boom/,
111
+ );
112
+ } finally {
113
+ cleanup();
114
+ }
115
+ });
116
+ });
package/src/index.test.ts CHANGED
@@ -4,10 +4,104 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  import { buildFixtureChatDb } from "./fixtures/build-chat-db";
7
- import { IMessageAdapterError, _internal, createIMessageAdapter } from "./index";
7
+ import {
8
+ IMessageAdapterError,
9
+ type OsascriptSpawn,
10
+ _internal,
11
+ createIMessageAdapter,
12
+ runOsascript,
13
+ } from "./index";
8
14
 
9
15
  const { isSafeHandle, escapeAppleScriptString, validateChatDbPath } = _internal;
10
16
 
17
+ /**
18
+ * Build a deterministic fake of `child_process.spawn` for {@link runOsascript}
19
+ * tests — no real process. `behavior` decides which lifecycle events fire and
20
+ * is invoked on a microtask so the runner has wired its listeners first.
21
+ */
22
+ function fakeSpawn(behavior: {
23
+ readonly stderrChunks?: readonly string[];
24
+ readonly closeCode?: number | null;
25
+ readonly emitError?: Error;
26
+ /** Force `stdin`/`stderr` to be null to exercise the optional-chaining guards. */
27
+ readonly nullStreams?: boolean;
28
+ }): { spawn: OsascriptSpawn; getWritten: () => string | undefined; ended: () => boolean } {
29
+ let written: string | undefined;
30
+ let didEnd = false;
31
+ const spawn: OsascriptSpawn = (command, args, options) => {
32
+ // The runner must always invoke osascript reading the script from stdin.
33
+ expect(command).toBe("osascript");
34
+ expect(args).toEqual(["-"]);
35
+ expect(options).toEqual({ stdio: ["pipe", "ignore", "pipe"] });
36
+ const handlers: Record<string, (arg: never) => void> = {};
37
+ const stderrHandlers: ((chunk: Buffer) => void)[] = [];
38
+ queueMicrotask(() => {
39
+ for (const c of behavior.stderrChunks ?? []) {
40
+ for (const h of stderrHandlers) h(Buffer.from(c, "utf8"));
41
+ }
42
+ if (behavior.emitError) handlers["error"]?.(behavior.emitError as never);
43
+ else handlers["close"]?.((behavior.closeCode ?? 0) as never);
44
+ });
45
+ return {
46
+ stderr: behavior.nullStreams ? null : { on: (_e, cb) => stderrHandlers.push(cb) },
47
+ stdin: behavior.nullStreams
48
+ ? null
49
+ : {
50
+ write: (chunk: string) => {
51
+ written = chunk;
52
+ },
53
+ end: () => {
54
+ didEnd = true;
55
+ },
56
+ },
57
+ on: (event: string, cb: (arg: never) => void) => {
58
+ handlers[event] = cb;
59
+ },
60
+ };
61
+ };
62
+ return { spawn, getWritten: () => written, ended: () => didEnd };
63
+ }
64
+
65
+ describe("runOsascript (default-runner core, dependency-injected spawn)", () => {
66
+ test("writes the script to stdin and resolves on exit code 0", async () => {
67
+ const f = fakeSpawn({ closeCode: 0 });
68
+ await runOsascript(f.spawn, "tell app");
69
+ expect(f.getWritten()).toBe("tell app");
70
+ expect(f.ended()).toBe(true);
71
+ });
72
+
73
+ test("rejects with stderr text + code on a non-zero exit", async () => {
74
+ const f = fakeSpawn({ closeCode: 2, stderrChunks: ["bad ", "things"] });
75
+ await expect(runOsascript(f.spawn, "x")).rejects.toThrow(/osascript exited with 2: bad things/);
76
+ });
77
+
78
+ test("rejects with a clear message when spawn emits 'error'", async () => {
79
+ const boom = new Error("ENOENT osascript");
80
+ const f = fakeSpawn({ emitError: boom });
81
+ let caught: unknown;
82
+ try {
83
+ await runOsascript(f.spawn, "x");
84
+ } catch (e) {
85
+ caught = e;
86
+ }
87
+ expect(caught).toBeInstanceOf(IMessageAdapterError);
88
+ expect((caught as Error).message).toBe("osascript spawn failed");
89
+ // The originating error is preserved on the cause chain.
90
+ expect((caught as IMessageAdapterError).cause).toBe(boom);
91
+ });
92
+
93
+ test("null stdin/stderr streams are tolerated (optional-chaining guards)", async () => {
94
+ const f = fakeSpawn({ nullStreams: true, closeCode: 0 });
95
+ await runOsascript(f.spawn, "x");
96
+ // Nothing was written because stdin was null; the runner still resolved.
97
+ expect(f.getWritten()).toBeUndefined();
98
+ });
99
+
100
+ test("exposed on _internal for the adapter's wiring", () => {
101
+ expect(_internal.runOsascript).toBe(runOsascript);
102
+ });
103
+ });
104
+
11
105
  describe("isSafeHandle / escapeAppleScriptString / validateChatDbPath", () => {
12
106
  test("accepts well-formed iMessage handles", () => {
13
107
  expect(isSafeHandle("alice@example.com")).toBe(true);
package/src/index.ts CHANGED
@@ -274,13 +274,38 @@ function writeCursor(cursorPath: string, cursor: number): void {
274
274
 
275
275
  // ─── default osascript runner ──────────────────────────────────────────────
276
276
 
277
- const defaultOsascript = async (script: string): Promise<void> => {
278
- const { spawn } = await import("node:child_process");
277
+ /**
278
+ * Minimal structural type for the slice of `child_process.spawn` that the
279
+ * osascript runner uses. Keeping it explicit (rather than importing Node's
280
+ * `ChildProcess`) lets tests inject a deterministic fake without a real
281
+ * process spawn — mirroring the dependency-injection style used elsewhere in
282
+ * factory's adapters.
283
+ */
284
+ type SpawnedChild = {
285
+ readonly stdout?: { on(event: "data", cb: (chunk: Buffer) => void): void } | null;
286
+ readonly stderr: { on(event: "data", cb: (chunk: Buffer) => void): void } | null;
287
+ readonly stdin: { write(chunk: string): void; end(): void } | null;
288
+ on(event: "error", cb: (err: Error) => void): void;
289
+ on(event: "close", cb: (code: number | null) => void): void;
290
+ };
291
+
292
+ export type OsascriptSpawn = (
293
+ command: string,
294
+ args: readonly string[],
295
+ options: { stdio: readonly ["pipe", "ignore", "pipe"] },
296
+ ) => SpawnedChild;
297
+
298
+ /**
299
+ * Run `osascript` reading the script from stdin, using an injected `spawn`.
300
+ * Resolves on exit code 0; rejects with an {@link IMessageAdapterError} on a
301
+ * spawn failure or any non-zero exit (surfacing captured stderr).
302
+ */
303
+ export function runOsascript(spawn: OsascriptSpawn, script: string): Promise<void> {
279
304
  return new Promise<void>((resolve, reject) => {
280
305
  const head = "osascript";
281
306
  const child = spawn(head, ["-"], { stdio: ["pipe", "ignore", "pipe"] });
282
307
  const errBufs: Buffer[] = [];
283
- child.stderr.on("data", (b) => errBufs.push(b));
308
+ child.stderr?.on("data", (b) => errBufs.push(b));
284
309
  child.on("error", (e) => reject(new IMessageAdapterError("osascript spawn failed", e)));
285
310
  child.on("close", (code) => {
286
311
  if (code === 0) resolve();
@@ -291,9 +316,14 @@ const defaultOsascript = async (script: string): Promise<void> => {
291
316
  ),
292
317
  );
293
318
  });
294
- child.stdin.write(script);
295
- child.stdin.end();
319
+ child.stdin?.write(script);
320
+ child.stdin?.end();
296
321
  });
322
+ }
323
+
324
+ const defaultOsascript = async (script: string): Promise<void> => {
325
+ const { spawn } = await import("node:child_process");
326
+ return runOsascript(spawn as unknown as OsascriptSpawn, script);
297
327
  };
298
328
 
299
329
  // ─── chat.db row shape + query ──────────────────────────────────────────────
@@ -327,4 +357,5 @@ export const _internal = {
327
357
  isSafeHandle,
328
358
  escapeAppleScriptString,
329
359
  validateChatDbPath,
360
+ runOsascript,
330
361
  };