Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1163 lines
37 KiB
TypeScript

/**
* Unit coverage for the plugin's public export surface and the pure
* target/handle/parsing helpers (`isPhoneNumber`, `isEmail`,
* `normalizeIMessageTarget`, AppleScript/chat.db parsers, chunking). No macOS,
* chat.db, or live service — deterministic string/shape assertions only.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import imessagePlugin, {
appleDateToJsMs,
chatDbMessageToPublicShape,
formatPhoneNumber,
getLastChatDbAccessIssue,
IMESSAGE_SERVICE_NAME,
IMessageCliError,
IMessageConfigurationError,
// Event types
IMessageEventTypes,
IMessageNotSupportedError,
// Error classes
IMessagePluginError,
IMessageService,
isEmail,
// Type utilities
isPhoneNumber,
isValidIMessageTarget,
MAX_IMESSAGE_MESSAGE_LENGTH,
normalizeContactHandle,
normalizeIMessageTarget,
openChatDb,
parseChatsFromAppleScript,
// Parsing functions
parseContactsOutput,
parseMessagesFromAppleScript,
splitMessageForIMessage,
} from "../src/index";
const runtimeRequire = createRequire(import.meta.url);
// ============================================================
// Plugin exports
// ============================================================
describe("iMessage plugin exports", () => {
it("exports plugin metadata", () => {
expect(imessagePlugin.name).toBe("imessage");
expect(imessagePlugin.description).toContain("iMessage");
expect(Array.isArray(imessagePlugin.actions)).toBe(true);
expect(Array.isArray(imessagePlugin.providers)).toBe(true);
expect(Array.isArray(imessagePlugin.services)).toBe(true);
});
it("exports service", () => {
expect(IMessageService).toBeDefined();
});
it("exports parsing utility functions", () => {
expect(parseMessagesFromAppleScript).toBeDefined();
expect(parseChatsFromAppleScript).toBeDefined();
});
it("exports constants", () => {
expect(IMESSAGE_SERVICE_NAME).toBe("imessage");
expect(MAX_IMESSAGE_MESSAGE_LENGTH).toBe(4000);
expect(IMessageEventTypes.MESSAGE_RECEIVED).toBe("IMESSAGE_MESSAGE_RECEIVED");
expect(IMessageEventTypes.MESSAGE_SENT).toBe("IMESSAGE_MESSAGE_SENT");
expect(IMessageEventTypes.CONNECTION_READY).toBe("IMESSAGE_CONNECTION_READY");
expect(IMessageEventTypes.ERROR).toBe("IMESSAGE_ERROR");
});
});
// ============================================================
// isPhoneNumber
// ============================================================
describe("isPhoneNumber", () => {
it("accepts valid US phone numbers", () => {
expect(isPhoneNumber("+15551234567")).toBe(true);
expect(isPhoneNumber("15551234567")).toBe(true);
});
it("accepts formatted phone numbers", () => {
expect(isPhoneNumber("1-555-123-4567")).toBe(true);
expect(isPhoneNumber("(555) 123-4567")).toBe(true);
expect(isPhoneNumber("555.123.4567")).toBe(true);
});
it("accepts international phone numbers", () => {
expect(isPhoneNumber("+44 7700 900000")).toBe(true);
expect(isPhoneNumber("+61412345678")).toBe(true);
});
it("rejects emails", () => {
expect(isPhoneNumber("test@example.com")).toBe(false);
});
it("rejects too-short numbers", () => {
expect(isPhoneNumber("12345")).toBe(false);
expect(isPhoneNumber("123")).toBe(false);
});
it("rejects plain text", () => {
expect(isPhoneNumber("hello world")).toBe(false);
expect(isPhoneNumber("not a phone")).toBe(false);
});
it("rejects empty string", () => {
expect(isPhoneNumber("")).toBe(false);
});
});
// ============================================================
// isEmail
// ============================================================
describe("isEmail", () => {
it("accepts valid email addresses", () => {
expect(isEmail("test@example.com")).toBe(true);
expect(isEmail("user.name@domain.co.uk")).toBe(true);
expect(isEmail("admin@sub.domain.org")).toBe(true);
});
it("rejects phone numbers", () => {
expect(isEmail("+15551234567")).toBe(false);
});
it("rejects plain text", () => {
expect(isEmail("not an email")).toBe(false);
expect(isEmail("hello")).toBe(false);
});
it("rejects partial addresses", () => {
expect(isEmail("@domain.com")).toBe(false);
expect(isEmail("user@")).toBe(false);
});
it("rejects empty string", () => {
expect(isEmail("")).toBe(false);
});
});
// ============================================================
// isValidIMessageTarget
// ============================================================
describe("isValidIMessageTarget", () => {
it("accepts phone numbers", () => {
expect(isValidIMessageTarget("+15551234567")).toBe(true);
});
it("accepts email addresses", () => {
expect(isValidIMessageTarget("user@example.com")).toBe(true);
});
it("accepts chat_id: prefixed targets", () => {
expect(isValidIMessageTarget("chat_id:iMessage;+;chat12345")).toBe(true);
});
it("rejects invalid targets", () => {
expect(isValidIMessageTarget("hello world")).toBe(false);
expect(isValidIMessageTarget("123")).toBe(false);
});
it("handles whitespace", () => {
expect(isValidIMessageTarget(" +15551234567 ")).toBe(true);
});
});
// ============================================================
// normalizeIMessageTarget
// ============================================================
describe("normalizeIMessageTarget", () => {
it("returns null for empty string", () => {
expect(normalizeIMessageTarget("")).toBeNull();
expect(normalizeIMessageTarget(" ")).toBeNull();
});
it("preserves chat_id: prefix", () => {
expect(normalizeIMessageTarget("chat_id:12345")).toBe("chat_id:12345");
});
it("strips imessage: prefix", () => {
const result = normalizeIMessageTarget("imessage:+15551234567");
expect(result).toBe("+15551234567");
});
it("trims whitespace", () => {
expect(normalizeIMessageTarget(" +15551234567 ")).toBe("+15551234567");
});
it("returns phone/email as-is", () => {
expect(normalizeIMessageTarget("+15551234567")).toBe("+15551234567");
expect(normalizeIMessageTarget("user@example.com")).toBe("user@example.com");
});
});
// ============================================================
// formatPhoneNumber
// ============================================================
describe("formatPhoneNumber", () => {
it("removes formatting characters", () => {
expect(formatPhoneNumber("+1 (555) 123-4567")).toBe("+15551234567");
});
it("adds + prefix for international numbers > 10 digits", () => {
expect(formatPhoneNumber("15551234567")).toBe("+15551234567");
});
it("preserves existing + prefix", () => {
expect(formatPhoneNumber("+15551234567")).toBe("+15551234567");
});
it("does not add + for 10-digit numbers", () => {
expect(formatPhoneNumber("5551234567")).toBe("5551234567");
});
it("handles dots and spaces", () => {
expect(formatPhoneNumber("555.123.4567")).toBe("5551234567");
});
});
// ============================================================
// splitMessageForIMessage
// ============================================================
describe("splitMessageForIMessage", () => {
it("returns single chunk for short messages", () => {
const result = splitMessageForIMessage("Hello world");
expect(result).toEqual(["Hello world"]);
});
it("returns single chunk for exactly max-length messages", () => {
const text = "a".repeat(MAX_IMESSAGE_MESSAGE_LENGTH);
const result = splitMessageForIMessage(text);
expect(result).toHaveLength(1);
expect(result[0]).toBe(text);
});
it("splits long messages at word boundaries", () => {
const words = Array.from({ length: 500 }, (_, i) => `word${i}`).join(" ");
const result = splitMessageForIMessage(words, 100);
expect(result.length).toBeGreaterThan(1);
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(100);
}
});
it("prefers newline break points", () => {
const text = `${"a".repeat(60)}\n${"b".repeat(30)}`;
const result = splitMessageForIMessage(text, 80);
expect(result).toHaveLength(2);
expect(result[0]).toBe("a".repeat(60));
expect(result[1]).toBe("b".repeat(30));
});
it("handles text with no break points", () => {
const text = "a".repeat(200);
const result = splitMessageForIMessage(text, 100);
expect(result.length).toBeGreaterThan(1);
// All text should be preserved
expect(result.join("")).toBe(text);
});
it("returns empty array for empty string", () => {
const result = splitMessageForIMessage("");
expect(result).toEqual([""]);
});
});
// ============================================================
// parseMessagesFromAppleScript
// ============================================================
describe("parseMessagesFromAppleScript", () => {
it("parses a single message line", () => {
const input = "msg001\tHello there\t1700000000000\t0\tchat123\t+15551234567";
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg001");
expect(result[0].text).toBe("Hello there");
expect(result[0].timestamp).toBe(1700000000000);
expect(result[0].isFromMe).toBe(false);
expect(result[0].chatId).toBe("chat123");
expect(result[0].handle).toBe("+15551234567");
expect(result[0].hasAttachments).toBe(false);
});
it("parses multiple message lines", () => {
const input = [
"msg001\tHello\t1700000000000\t0\tchat1\t+15551111111",
"msg002\tWorld\t1700000001000\t1\tchat1\t+15552222222",
"msg003\tTest\t1700000002000\ttrue\tchat2\tuser@test.com",
].join("\n");
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(3);
expect(result[0].text).toBe("Hello");
expect(result[0].isFromMe).toBe(false);
expect(result[1].text).toBe("World");
expect(result[1].isFromMe).toBe(true);
expect(result[2].text).toBe("Test");
expect(result[2].isFromMe).toBe(true);
});
it("returns empty array for empty string", () => {
expect(parseMessagesFromAppleScript("")).toEqual([]);
});
it("returns empty array for whitespace-only input", () => {
expect(parseMessagesFromAppleScript(" \n \n ")).toEqual([]);
});
it("skips lines with fewer than 6 fields", () => {
const input = "partial\tdata\n" + "msg001\tHello\t1700000000000\t0\tchat1\t+15551234567";
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg001");
});
it("handles is_from_me variations", () => {
const lines = [
"m1\ttext\t1000\t1\tchat\tsender",
"m2\ttext\t1000\ttrue\tchat\tsender",
"m3\ttext\t1000\tTrue\tchat\tsender",
"m4\ttext\t1000\t0\tchat\tsender",
"m5\ttext\t1000\tfalse\tchat\tsender",
].join("\n");
const result = parseMessagesFromAppleScript(lines);
expect(result[0].isFromMe).toBe(true);
expect(result[1].isFromMe).toBe(true);
expect(result[2].isFromMe).toBe(true);
expect(result[3].isFromMe).toBe(false);
expect(result[4].isFromMe).toBe(false);
});
it("handles invalid date by setting timestamp to 0", () => {
const input = "msg001\tHello\tinvalid_date\t0\tchat1\tsender";
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].timestamp).toBe(0);
});
it("handles empty fields gracefully", () => {
// Use boundary placeholders so trim() doesn't strip leading/trailing tabs
const input = ".\t\t1000\t0\t\t.";
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(".");
expect(result[0].text).toBe("");
expect(result[0].chatId).toBe("");
expect(result[0].handle).toBe(".");
});
it("returns empty for all-tab line (tabs trimmed as whitespace)", () => {
const input = "\t\t1000\t0\t\t";
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(0);
});
it("handles extra tab-separated fields (forward compat)", () => {
const input = "msg001\tHello\t1000\t1\tchat1\tsender\textra1\textra2";
const result = parseMessagesFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg001");
});
});
// ============================================================
// parseChatsFromAppleScript
// ============================================================
describe("parseChatsFromAppleScript", () => {
it("parses a single chat line", () => {
const input = "chat123\tWork Group\t5\t1700000000000";
const result = parseChatsFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].chatId).toBe("chat123");
expect(result[0].displayName).toBe("Work Group");
expect(result[0].chatType).toBe("group");
expect(result[0].participants).toEqual([]);
});
it("parses multiple chat lines", () => {
const input = [
"chat1\tWork\t5\t1700000000000",
"chat2\tFamily\t3\t1700000001000",
"chat3\t\t1\t1700000002000",
].join("\n");
const result = parseChatsFromAppleScript(input);
expect(result).toHaveLength(3);
expect(result[0].chatType).toBe("group");
expect(result[1].chatType).toBe("group");
expect(result[2].chatType).toBe("direct");
});
it("returns empty array for empty string", () => {
expect(parseChatsFromAppleScript("")).toEqual([]);
});
it("returns empty array for whitespace-only input", () => {
expect(parseChatsFromAppleScript(" \n \n ")).toEqual([]);
});
it("classifies direct chats (participant_count <= 1)", () => {
const input = "chat1\tJohn\t1\t1700000000000";
const result = parseChatsFromAppleScript(input);
expect(result[0].chatType).toBe("direct");
});
it("classifies group chats (participant_count > 1)", () => {
const input = "chat1\tTeam\t2\t1700000000000";
const result = parseChatsFromAppleScript(input);
expect(result[0].chatType).toBe("group");
});
it("handles empty display name", () => {
const input = "chat1\t\t1\t1700000000000";
const result = parseChatsFromAppleScript(input);
expect(result[0].displayName).toBeUndefined();
});
it("handles invalid participant count", () => {
const input = "chat1\tTest\tnotanumber\t1700000000000";
const result = parseChatsFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].chatType).toBe("direct");
});
it("skips lines with fewer than 4 fields", () => {
const input = "short\tdata\n" + "chat1\tTest\t3\t1700000000000";
const result = parseChatsFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].chatId).toBe("chat1");
});
it("handles extra tab-separated fields (forward compat)", () => {
const input = "chat1\tTest\t3\t1700000000000\textra";
const result = parseChatsFromAppleScript(input);
expect(result).toHaveLength(1);
expect(result[0].chatId).toBe("chat1");
});
it("parses AppleScript list output from getChats()", () => {
const input =
'{"iMessage;+;chat123", "Work Group"}, {"iMessage;-;+15551234567", missing value}';
const result = parseChatsFromAppleScript(input);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
chatId: "iMessage;+;chat123",
chatType: "group",
displayName: "Work Group",
participants: [],
});
expect(result[1]).toEqual({
chatId: "iMessage;-;+15551234567",
chatType: "direct",
displayName: undefined,
participants: [],
});
});
});
// ============================================================
// Error classes
// ============================================================
describe("Error classes", () => {
it("IMessagePluginError has correct properties", () => {
const error = new IMessagePluginError("test error", "TEST_CODE", {
key: "value",
});
expect(error.message).toBe("test error");
expect(error.code).toBe("TEST_CODE");
expect(error.details).toEqual({ key: "value" });
expect(error.name).toBe("IMessagePluginError");
expect(error instanceof Error).toBe(true);
});
it("IMessageConfigurationError sets correct code", () => {
const error = new IMessageConfigurationError("bad config", "cli_path");
expect(error.code).toBe("CONFIGURATION_ERROR");
expect(error.details).toEqual({ setting: "cli_path" });
expect(error.name).toBe("IMessageConfigurationError");
expect(error instanceof IMessagePluginError).toBe(true);
});
it("IMessageNotSupportedError has default message", () => {
const error = new IMessageNotSupportedError();
expect(error.message).toBe("iMessage is only supported on macOS");
expect(error.code).toBe("NOT_SUPPORTED");
expect(error.name).toBe("IMessageNotSupportedError");
});
it("IMessageNotSupportedError accepts custom message", () => {
const error = new IMessageNotSupportedError("custom msg");
expect(error.message).toBe("custom msg");
});
it("IMessageCliError includes exit code", () => {
const error = new IMessageCliError("command failed", 1);
expect(error.code).toBe("CLI_ERROR");
expect(error.details).toEqual({ exitCode: 1 });
expect(error.name).toBe("IMessageCliError");
});
it("IMessageCliError handles undefined exit code", () => {
const error = new IMessageCliError("command failed");
expect(error.details).toBeUndefined();
});
});
// ============================================================
// Action validation
// ============================================================
// The former iMessage-specific send action is now handled by the iMessage
// MessageConnector registered by IMessageService.registerSendHandlers. The
// canonical send path is MESSAGE operation=send, so the dedicated action-shape
// and validate/handler tests that lived here have been retired with the action.
// ============================================================
// chat.db reader (bun:sqlite) — inbound polling
// ============================================================
describe("appleDateToJsMs", () => {
it("returns 0 for 0 or negative input", () => {
expect(appleDateToJsMs(0)).toBe(0);
expect(appleDateToJsMs(-100)).toBe(0);
});
it("converts a seconds-scale Apple date to JS ms", () => {
// 1 second past Apple epoch → 2001-01-01T00:00:01Z
const ms = appleDateToJsMs(1);
expect(new Date(ms).toISOString()).toBe("2001-01-01T00:00:01.000Z");
});
it("converts a nanoseconds-scale Apple date to JS ms", () => {
// 1e15 ns is clearly past the 1e12 split point, so the implementation
// takes the nanoseconds branch: deltaMs = 1e15 / 1e6 = 1e9 ms past
// the Apple epoch (≈ 11.6 days).
expect(appleDateToJsMs(1e15)).toBe(Date.UTC(2001, 0, 1) + 1e9);
});
it("handles a realistic modern timestamp", () => {
// 2024-06-15T12:00:00Z in nanoseconds since Apple epoch:
// (2024-06-15 - 2001-01-01) in ms * 1_000_000
const targetMs = Date.UTC(2024, 5, 15, 12, 0, 0);
const deltaNs = (targetMs - Date.UTC(2001, 0, 1)) * 1_000_000;
expect(appleDateToJsMs(deltaNs)).toBe(targetMs);
});
});
describe("chatDbMessageToPublicShape", () => {
it("maps every ChatDbMessage field onto the public IMessageMessage shape", () => {
const result = chatDbMessageToPublicShape({
rowId: 42,
guid: "guid-42",
text: "hey",
kind: "text",
handle: "+15551234567",
chatId: "iMessage;-;+15551234567",
chatType: "direct",
displayName: null,
timestamp: 1_700_000_000_000,
isFromMe: false,
service: "iMessage",
isSent: true,
isDelivered: true,
isRead: false,
dateRead: 0,
dateEdited: 0,
dateRetracted: 0,
replyToGuid: null,
reaction: null,
attachments: [
{
guid: "att-1",
filename: "/tmp/image.png",
uti: "public.png",
mimeType: "image/png",
totalBytes: 123,
isSticker: false,
},
],
});
expect(result).toEqual({
id: "42",
text: "hey",
handle: "+15551234567",
chatId: "iMessage;-;+15551234567",
timestamp: 1_700_000_000_000,
isFromMe: false,
hasAttachments: true,
attachmentPaths: ["/tmp/image.png"],
});
});
});
describe("openChatDb + ChatDbReader (bun:sqlite backed)", () => {
// These tests create a real SQLite file on disk and open it via the
// same openChatDb() the service uses, so the entire query path —
// prepared statements, joins, text filtering, ROWID cursor, and null
// handling are exercised against a real SQLite engine under whichever
// runtime is driving the suite.
type FixtureDatabase = {
run(sql: string): unknown;
close(): void;
};
type SqlStatement = {
all(...params: unknown[]): unknown[];
run?(...params: unknown[]): unknown;
};
type BunFixtureDatabase = {
run?(sql: string): unknown;
exec?(sql: string): unknown;
query?(sql: string): SqlStatement;
close(): void;
};
let createDatabase: ((path: string) => FixtureDatabase) | null = null;
async function getDatabase() {
if (createDatabase) {
return createDatabase;
}
try {
const mod = (await import("node:sqlite")) as {
DatabaseSync?: new (
path: string
) => {
exec(sql: string): unknown;
close(): void;
};
default?: {
DatabaseSync?: new (
path: string
) => {
exec(sql: string): unknown;
close(): void;
};
};
};
const DatabaseSync = mod.DatabaseSync ?? mod.default?.DatabaseSync;
if (DatabaseSync) {
createDatabase = (path: string) => {
const db = new DatabaseSync(path);
return {
run(sql: string) {
return db.exec(sql);
},
close() {
db.close();
},
};
};
return createDatabase;
}
} catch {
// Fall through to Bun's SQLite runtime.
}
try {
const mod = runtimeRequire("bun:sqlite") as {
Database?: new (path: string) => BunFixtureDatabase;
default?: new (path: string) => BunFixtureDatabase;
};
const Database = mod.Database ?? mod.default;
if (!Database) {
return null;
}
createDatabase = (path: string) => {
const db = new Database(path);
return {
run(sql: string) {
if (typeof db.run === "function") {
return db.run(sql);
}
if (typeof db.exec === "function") {
return db.exec(sql);
}
if (typeof db.query === "function") {
const statement = db.query(sql);
if (typeof statement.run === "function") {
return statement.run();
}
return statement.all();
}
throw new Error("SQLite runtime does not expose a fixture-compatible execute method");
},
close() {
db.close();
},
};
};
return createDatabase;
} catch {
return null;
}
}
async function makeFixtureDb(
skip: (reason?: string) => void
): Promise<{ path: string; cleanup: () => void }> {
const dir = mkdtempSync(join(tmpdir(), "imessage-chatdb-test-"));
const path = join(dir, "chat.db");
const openDatabase = await getDatabase();
if (!openDatabase) {
rmSync(dir, { recursive: true, force: true });
skip("No supported SQLite runtime is available for iMessage fixture tests");
throw new Error("unreachable");
}
const db = openDatabase(path);
db.run(`
CREATE TABLE handle (
ROWID INTEGER PRIMARY KEY,
id TEXT,
service TEXT
);
`);
db.run(`
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
display_name TEXT,
service_name TEXT,
style INTEGER,
last_read_message_timestamp INTEGER
);
`);
db.run(`
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
guid TEXT,
text TEXT,
attributedBody BLOB,
date INTEGER,
date_read INTEGER,
date_edited INTEGER,
date_retracted INTEGER,
is_from_me INTEGER,
is_read INTEGER,
is_sent INTEGER,
is_delivered INTEGER,
item_type INTEGER,
reply_to_guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
associated_message_emoji TEXT,
cache_has_attachments INTEGER,
service TEXT,
handle_id INTEGER
);
`);
db.run(`
CREATE TABLE chat_message_join (
chat_id INTEGER,
message_id INTEGER
);
`);
db.run(`
CREATE TABLE chat_handle_join (
chat_id INTEGER,
handle_id INTEGER
);
`);
db.run(`
CREATE TABLE attachment (
ROWID INTEGER PRIMARY KEY,
guid TEXT,
transfer_name TEXT,
filename TEXT,
mime_type TEXT,
uti TEXT,
total_bytes INTEGER,
is_sticker INTEGER
);
`);
db.run(`
CREATE TABLE message_attachment_join (
message_id INTEGER,
attachment_id INTEGER
);
`);
// Two contacts: +15551234567 (DM) and a group with +15559999999 + sender.
db.run("INSERT INTO handle (ROWID, id, service) VALUES (1, '+15551234567', 'iMessage')");
db.run("INSERT INTO handle (ROWID, id, service) VALUES (2, '+15559999999', 'iMessage')");
db.run(
"INSERT INTO chat (ROWID, chat_identifier, display_name, service_name, style, last_read_message_timestamp) VALUES (1, 'iMessage;-;+15551234567', NULL, 'iMessage', 45, 2000)"
);
db.run(
"INSERT INTO chat (ROWID, chat_identifier, display_name, service_name, style, last_read_message_timestamp) VALUES (2, 'iMessage;+;group-abc', 'Weekend Plans', 'iMessage', 43, 3000)"
);
db.run("INSERT INTO chat_handle_join (chat_id, handle_id) VALUES (1, 1)");
db.run("INSERT INTO chat_handle_join (chat_id, handle_id) VALUES (2, 2)");
// Seconds-scale Apple date for simplicity: 1000 = 2001-01-01 +1000s
db.run(
"INSERT INTO message (ROWID, guid, text, date, is_from_me, handle_id, service) VALUES (10, 'guid-10', 'first', 1000, 0, 1, 'iMessage')"
);
db.run(
"INSERT INTO message (ROWID, guid, text, date, is_from_me, handle_id, service) VALUES (11, 'guid-11', 'outbound reply', 2000, 1, 1, 'iMessage')"
);
db.run(
"INSERT INTO message (ROWID, guid, text, date, is_from_me, handle_id, service) VALUES (12, 'guid-12', 'group hello', 3000, 0, 2, 'iMessage')"
);
// Null-text row with no attributedBody — decoder miss, should be skipped.
db.run(
"INSERT INTO message (ROWID, guid, text, date, is_from_me, handle_id, service) VALUES (13, 'guid-13', NULL, 4000, 0, 1, 'iMessage')"
);
db.run("INSERT INTO chat_message_join (chat_id, message_id) VALUES (1, 10)");
db.run("INSERT INTO chat_message_join (chat_id, message_id) VALUES (1, 11)");
db.run("INSERT INTO chat_message_join (chat_id, message_id) VALUES (2, 12)");
db.run("INSERT INTO chat_message_join (chat_id, message_id) VALUES (1, 13)");
db.close();
return {
path,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}
it("returns messages in ROWID order past the cursor", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
expect(reader).not.toBeNull();
if (!reader) return;
const rows = reader.fetchNewMessages(0, 100);
const rowIds = rows.map((r) => r.rowId);
// ROWID 13 has null text and no attributedBody; it's still returned
// (with empty text) so the cursor can advance past it — the service
// layer skips empty-text rows before dispatch.
expect(rowIds).toEqual([10, 11, 12, 13]);
reader.close();
} finally {
cleanup();
}
});
it("classifies direct vs group chats via chat.style", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const rows = reader.fetchNewMessages(0, 100);
const direct = rows.find((r) => r.rowId === 10);
const group = rows.find((r) => r.rowId === 12);
expect(direct?.chatType).toBe("direct");
expect(group?.chatType).toBe("group");
expect(group?.displayName).toBe("Weekend Plans");
reader.close();
} finally {
cleanup();
}
});
it("marks outbound messages with isFromMe=true", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const outbound = reader.fetchNewMessages(0, 100).find((r) => r.rowId === 11);
expect(outbound?.isFromMe).toBe(true);
expect(outbound?.text).toBe("outbound reply");
reader.close();
} finally {
cleanup();
}
});
it("respects the sinceRowId cursor", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const afterTen = reader.fetchNewMessages(10, 100);
expect(afterTen.map((r) => r.rowId)).toEqual([11, 12, 13]);
const afterEleven = reader.fetchNewMessages(11, 100);
expect(afterEleven.map((r) => r.rowId)).toEqual([12, 13]);
reader.close();
} finally {
cleanup();
}
});
it("listMessages returns the newest rows in chronological order", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const rows = reader.listMessages({ limit: 3 });
expect(rows.map((r) => r.rowId)).toEqual([11, 12, 13]);
reader.close();
} finally {
cleanup();
}
});
it("listMessages can scope reads to a single chat identifier", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const rows = reader.listMessages({
chatId: "iMessage;-;+15551234567",
limit: 10,
});
expect(rows.map((r) => r.rowId)).toEqual([10, 11, 13]);
expect(new Set(rows.map((r) => r.chatId))).toEqual(new Set(["iMessage;-;+15551234567"]));
reader.close();
} finally {
cleanup();
}
});
it("getLatestRowId returns the maximum ROWID in the table", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
// Max ROWID in the fixture is 13 (the null-text row), so
// getLatestRowId must still see it — only fetchNewMessages filters it.
expect(reader.getLatestRowId()).toBe(13);
reader.close();
} finally {
cleanup();
}
});
it("returns null-text rows with empty text so the service layer can advance its cursor", async ({
skip,
}) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const rows = reader.fetchNewMessages(0, 100);
// ROWID 13 is included so the service cursor can skip past it, but
// its text is empty so the service will drop it before dispatch.
const undecodable = rows.find((r) => r.rowId === 13);
expect(undecodable).toBeDefined();
expect(undecodable?.text).toBe("");
reader.close();
} finally {
cleanup();
}
});
it("limit parameter caps the batch size", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
const rows = reader.fetchNewMessages(0, 2);
expect(rows.length).toBeLessThanOrEqual(2);
reader.close();
} finally {
cleanup();
}
});
it("close() is idempotent and subsequent reads return empty", async ({ skip }) => {
const { path, cleanup } = await makeFixtureDb(skip);
try {
const reader = await openChatDb(path);
if (!reader) return;
reader.close();
reader.close(); // should not throw
const rows = reader.fetchNewMessages(0, 100);
expect(rows).toEqual([]);
} finally {
cleanup();
}
});
it("returns null when the chat.db path does not exist", async () => {
const reader = await openChatDb("/nonexistent/path/to/chat.db");
expect(reader).toBeNull();
});
it("logs one actionable warning for repeated failures on the same chat.db path", async ({
skip,
}) => {
const openDatabase = await getDatabase();
if (!openDatabase) {
skip("No supported SQLite runtime is available for iMessage open-failure diagnostics");
return;
}
const missingPath = join(
tmpdir(),
`imessage-missing-chatdb-${Date.now()}-${Math.random()}`,
"chat.db"
);
const warnings: string[] = [];
const debugs: string[] = [];
const diagnosticsLogger = {
warn: (message: string) => warnings.push(message),
debug: (message: string) => debugs.push(message),
};
await openChatDb(missingPath, { diagnosticsLogger });
// The runtime's tryLoadSqlite path is stricter than the test helper's:
// it tries `bun:sqlite` via createRequire then `await import("node:sqlite")`.
// On some CI configurations (notably bun-running-vitest where the worker
// can't see bun:sqlite and node:sqlite ESM resolution differs from the
// test helper's), tryLoadSqlite returns null even when our helper's
// node:sqlite import succeeds. In that case the warning we're testing
// for never fires — skip rather than fail.
const accessIssueProbe = getLastChatDbAccessIssue(missingPath);
if (accessIssueProbe?.code === "sqlite_unavailable") {
skip(
"Runtime tryLoadSqlite resolved to null in this worker — open-failure warning path is not exercised"
);
return;
}
await openChatDb(missingPath, { diagnosticsLogger });
const matchingWarnings = warnings.filter((message) =>
message.includes(`Failed to open chat.db at ${missingPath}`)
);
const matchingDebugs = debugs.filter((message) =>
message.includes(`chat.db at ${missingPath} is still unavailable`)
);
expect(matchingWarnings).toHaveLength(1);
expect(matchingWarnings[0]).toContain("Full Disk Access");
expect(matchingDebugs).toHaveLength(1);
const accessIssue = getLastChatDbAccessIssue(missingPath);
expect(accessIssue?.code).toBe("open_failed");
expect(accessIssue?.permissionAction?.type).toBe("full_disk_access");
expect(accessIssue?.permissionAction?.url).toContain("Privacy_AllFiles");
});
});
// ============================================================
// Contacts reader normalization and legacy fixture parsing
// ============================================================
describe("normalizeContactHandle", () => {
it("returns empty for empty input", () => {
expect(normalizeContactHandle("")).toBe("");
expect(normalizeContactHandle(" ")).toBe("");
});
it("lowercases emails", () => {
expect(normalizeContactHandle("Alex@Example.COM")).toBe("alex@example.com");
});
it("strips formatting from phone numbers while preserving a leading +", () => {
expect(normalizeContactHandle("+1 (555) 123-4567")).toBe("+15551234567");
expect(normalizeContactHandle("(555) 123.4567")).toBe("5551234567");
expect(normalizeContactHandle("555 123 4567")).toBe("5551234567");
});
it("trims whitespace around the input", () => {
expect(normalizeContactHandle(" +15551234567 ")).toBe("+15551234567");
});
});
describe("parseContactsOutput", () => {
it("returns an empty map for empty or whitespace-only input", () => {
expect(parseContactsOutput("").size).toBe(0);
expect(parseContactsOutput(" \n\n ").size).toBe(0);
});
it("parses phone and email rows with tab-delimited fields", () => {
const input = [
"phone\t+1 (555) 123-4567\tAlex Chen",
"email\tmom@example.com\tMom",
"phone\t+15550000000\tDad",
].join("\n");
const map = parseContactsOutput(input);
expect(map.size).toBe(3);
expect(map.get("+15551234567")?.name).toBe("Alex Chen");
expect(map.get("mom@example.com")?.name).toBe("Mom");
expect(map.get("+15550000000")?.name).toBe("Dad");
});
it("skips rows with missing handle or name", () => {
const input = [
"phone\t\tNo handle here",
"phone\t+15551234567\t",
"phone\t+15559999999\tValid",
].join("\n");
const map = parseContactsOutput(input);
expect(map.size).toBe(1);
expect(map.get("+15559999999")?.name).toBe("Valid");
});
it("keeps the first entry when the same handle appears twice", () => {
const input = ["phone\t+15551234567\tFirst Name", "phone\t+15551234567\tSecond Name"].join(
"\n"
);
const map = parseContactsOutput(input);
expect(map.get("+15551234567")?.name).toBe("First Name");
});
it("ignores lines with fewer than three fields", () => {
const input = [
"phone\t+15551234567", // only 2 fields
"just one field",
"phone\t+15559999999\tOK",
].join("\n");
const map = parseContactsOutput(input);
expect(map.size).toBe(1);
expect(map.get("+15559999999")?.name).toBe("OK");
});
});