200 lines
11 KiB
TypeScript
200 lines
11 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
describe("Android mobile platform contract", () => {
|
|
it("keeps authentication actions reachable while the keyboard is open", () => {
|
|
const signIn = readFileSync(resolve(mobileRoot, "app/sign-in.tsx"), "utf8");
|
|
expect(signIn).toContain("KeyboardAvoidingView");
|
|
expect(signIn).toContain("Keyboard.dismiss");
|
|
expect(signIn).toContain("keyboardDismissMode");
|
|
expect(signIn).toContain("ScrollView");
|
|
});
|
|
|
|
it("pins the thread footer above the keyboard and device safe area", () => {
|
|
const config = JSON.parse(readFileSync(resolve(mobileRoot, "app.json"), "utf8"));
|
|
const packageJson = JSON.parse(readFileSync(resolve(mobileRoot, "package.json"), "utf8"));
|
|
const layout = readFileSync(resolve(mobileRoot, "app/_layout.tsx"), "utf8");
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
expect(config.expo.android.softwareKeyboardLayoutMode).toBe("resize");
|
|
expect(packageJson.dependencies["react-native-keyboard-controller"]).toBeTruthy();
|
|
expect(layout).toContain("KeyboardProvider");
|
|
expect(thread).toContain('from "react-native-keyboard-controller"');
|
|
expect(thread).toContain("KeyboardAvoidingView");
|
|
expect(thread).toContain('behavior="height"');
|
|
expect(thread).toContain("useHeaderHeight");
|
|
expect(thread).toContain("keyboardVerticalOffset={headerHeight}");
|
|
expect(thread).not.toContain("automaticOffset");
|
|
expect(thread).not.toContain("KeyboardStickyView");
|
|
expect(thread).toContain("useSafeAreaInsets");
|
|
expect(thread).toContain("Math.max(insets.bottom + 12, 24)");
|
|
});
|
|
|
|
it("requests live-update promotion and exposes its Android settings", () => {
|
|
const nativeRoot = resolve(
|
|
mobileRoot,
|
|
"modules/rakazo-notifications/android/src/main/java/com/rakazo/notifications",
|
|
);
|
|
const service = readFileSync(resolve(nativeRoot, "RakazoNotificationService.kt"), "utf8");
|
|
const module = readFileSync(resolve(nativeRoot, "RakazoNotificationsModule.kt"), "utf8");
|
|
const allowlist = readFileSync(resolve(nativeRoot, "EndpointAllowlist.kt"), "utf8");
|
|
const live = readFileSync(resolve(mobileRoot, "lib/live-notifications.ts"), "utf8");
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
expect(service).toContain("android.requestPromotedOngoing");
|
|
expect(service).toContain("liveStatusIcon(primary, avatarStyle)");
|
|
expect(service).toContain('rpc(endpoint, token, spaceId, "me"');
|
|
expect(service).not.toContain("showStarting");
|
|
expect(service).not.toContain("catch (_: IOException) {\n stop()");
|
|
expect(service).toContain("Expo push owns background completion and attention delivery");
|
|
expect(service).toContain("isAllowedNotificationEndpoint(storage.endpoint)");
|
|
expect(service).toContain("if (!isAllowedNotificationEndpoint(endpoint)) throw IOException");
|
|
expect(module).toContain("android.settings.APP_NOTIFICATION_PROMOTION_SETTINGS");
|
|
expect(module).not.toContain("settings.copy(liveConnection = false)");
|
|
expect(module).toContain("RakazoNotificationService.clearSession(context)");
|
|
expect(module).toContain("isAllowedNotificationEndpoint(endpoint)");
|
|
expect(module).toContain("storage.spaceId = spaceId");
|
|
expect(allowlist).toContain("isAllowedNotificationEndpoint");
|
|
expect(allowlist).toContain('scheme == "https"');
|
|
expect(allowlist).toContain("isLanOrLocalHost");
|
|
expect(live).toContain("normalizeApiBase(endpoint)");
|
|
expect(live).toMatch(
|
|
/export async function resumeLiveNotifications[\s\S]*normalizeApiBase\(endpoint\)[\s\S]*nativeNotifications\.resume\(parsed\.url/,
|
|
);
|
|
expect(service).toContain('connection.setRequestProperty("x-rakazo-space-id", spaceId)');
|
|
expect(service).toContain("storage.spaceId.isBlank()");
|
|
expect(service).toContain("private fun prepareHistorySpace(");
|
|
expect(service).toContain("knownCompleted.clear()");
|
|
expect(service).toContain("alertedAttention.clear()");
|
|
expect(service).toContain("SEEN_RUNS_SPACE_ID");
|
|
expect(service).toContain(
|
|
"getSharedPreferences(STATE_PREFERENCES, MODE_PRIVATE).edit().clear()",
|
|
);
|
|
expect(service).toContain("val generation = synchronized(sessionLock)");
|
|
expect(service).toContain("ACTION_THREAD_CHANGED");
|
|
expect(service).toContain(
|
|
"private fun runIfCurrent(generation: Long, action: () -> Unit): Boolean",
|
|
);
|
|
expect(service).toContain("private fun stopIfCurrent(generation: Long)");
|
|
expect(service).toContain("synchronized(sessionLock)");
|
|
expect(service).toContain("working.filterNot(::isOpenThread)");
|
|
expect(service).toContain("!run.notificationsEnabled || isOpenThread(run)");
|
|
expect(service).toMatch(
|
|
/waiting_input[\s\S]*filter \{ it\.notificationsEnabled \}[\s\S]*alertedAttention\.add/,
|
|
);
|
|
expect(service).toContain(
|
|
"if (openThreadId != null) openThreadId == run.threadId else openBotId == run.botId",
|
|
);
|
|
expect(service).toContain("manager.notify(run.threadId.hashCode(), notification)");
|
|
expect(service).toContain("THREAD_NOTIFICATION_IDS");
|
|
expect(service).toContain("cancel(threadId.hashCode())");
|
|
expect(module).toContain('AsyncFunction("setOpenThread")');
|
|
expect(live).toContain("setOpenNotificationThread");
|
|
expect(thread).toContain("if (!navigation.isFocused() || !notificationThreadId) return");
|
|
expect(service).toContain(
|
|
"fun clearSession(context: Context) {\n synchronized(sessionLock)",
|
|
);
|
|
expect(service).not.toContain("private fun postCompletion(");
|
|
expect(service).toContain('"rakazo://group-thread?groupId=');
|
|
expect(service).toMatch(/&spaceId=\$\{Uri\.encode\(run\.spaceId\)\}/);
|
|
expect(service).toContain('putString("rakazo.spaceId", run.spaceId)');
|
|
expect(thread).toContain("export default function ThreadRoute()");
|
|
expect(thread).toContain("selectSpace(requestedSpaceId)");
|
|
expect(thread).toContain("routeMatchesSelectedSpace) return <Thread />");
|
|
expect(service).toContain('if (run.groupId != null) put("groupId", run.groupId)');
|
|
expect(service).toContain('if (message.optString("runId") != run.runId) continue');
|
|
expect(service).toContain('if (block.optString("kind") == "handoff") return null');
|
|
expect(service).toContain("run?.threadId?.hashCode() ?: 0");
|
|
});
|
|
|
|
it("shows live updates only for working runs and ties the pill to a real bot", () => {
|
|
const service = readFileSync(
|
|
resolve(
|
|
mobileRoot,
|
|
"modules/rakazo-notifications/android/src/main/java/com/rakazo/notifications/RakazoNotificationService.kt",
|
|
),
|
|
"utf8",
|
|
);
|
|
expect(service).toContain(
|
|
"val working = active.filter(::isWorking).filter { it.notificationsEnabled }",
|
|
);
|
|
expect(service).toMatch(
|
|
/if \(visibleWorking\.isEmpty\(\)\) clearLive\(\) else showLive\(visibleWorking/,
|
|
);
|
|
expect(service).toMatch(
|
|
/private fun showLive\(active: List<RunRecord>[\s\S]*val primary = active\.first\(\)/,
|
|
);
|
|
expect(service).toContain('putString("rakazo.botId", run.botId)');
|
|
expect(service).toMatch(/if \(working\.isEmpty\(\)\) \{[\s\S]*stop\(\)[\s\S]*return[\s\S]*\}/);
|
|
});
|
|
|
|
it("centers the latest-message control and clears a thread's Android notifications when read", () => {
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
const notifications = readFileSync(resolve(mobileRoot, "lib/live-notifications.ts"), "utf8");
|
|
expect(thread).toContain('left: "50%"');
|
|
expect(thread).toContain("dismissThreadNotifications");
|
|
expect(notifications).toContain("getPresentedNotificationsAsync");
|
|
expect(notifications).toContain("dismissNotificationAsync");
|
|
});
|
|
|
|
it("reconciles finished agents and opens ordinary chats at the latest message", () => {
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
const scroll = readFileSync(resolve(mobileRoot, "lib/thread-scroll.ts"), "utf8");
|
|
expect(thread).toContain(
|
|
"const currentBotStatus = snap ? snap.run?.status : currentBot?.status",
|
|
);
|
|
expect(thread).toContain("key={threadKey}");
|
|
expect(scroll).toContain('this.contentReady && !this.currentState.detached ? "jump" : null');
|
|
});
|
|
|
|
it("stacks every currently working agent in a group footer", () => {
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
expect(thread).toContain("workingGroupBots.map");
|
|
expect(thread).toContain("inGroup && workingGroupBots.length > 0 ?");
|
|
expect(thread).toContain("workingGroupBots.length - index");
|
|
expect(thread).toContain("agents working");
|
|
});
|
|
|
|
it("keeps send and stop separate while steering active work", () => {
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
const stopStart = thread.indexOf("async function stop()");
|
|
const stopSource = thread.slice(stopStart, thread.indexOf("const answerMessage", stopStart));
|
|
expect(stopStart).toBeGreaterThan(-1);
|
|
expect(thread).toContain('accessibilityLabel="Send"');
|
|
expect(thread).toContain('accessibilityLabel="Stop"');
|
|
expect(thread).not.toContain("Messages sent now guide the next turn.");
|
|
expect(thread).not.toContain("Steer ");
|
|
expect(thread).not.toContain("steering message");
|
|
expect(thread).toContain("`Message ${name}`");
|
|
expect(thread).toContain("const clientNonce = newClientNonce()");
|
|
expect(thread).toContain("Work stopped, but the thread could not refresh");
|
|
expect(stopSource).toContain("const targetBotId = botId;");
|
|
expect(stopSource).toContain("const targetGroupId = groupId;");
|
|
expect(stopSource).toContain(
|
|
"targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },",
|
|
);
|
|
expect(stopSource).toMatch(
|
|
/if \(isCurrentTarget\(targetBotId, targetGroupId\)\) \{\s*setError\(err instanceof Error \? err\.message : "Failed to stop work"\);/,
|
|
);
|
|
expect(stopSource).toMatch(
|
|
/if \(isCurrentTarget\(targetBotId, targetGroupId\)\) \{\s*(?:const detail = [^\n]+;\s*)?setError\(`Work stopped, but the thread could not refresh: \$\{detail\}`\);/,
|
|
);
|
|
});
|
|
|
|
it("shows agent notification silence in the menu, inbox avatar, and DM header only", () => {
|
|
const index = readFileSync(resolve(mobileRoot, "app/index.tsx"), "utf8");
|
|
const thread = readFileSync(resolve(mobileRoot, "app/thread.tsx"), "utf8");
|
|
const avatar = readFileSync(resolve(mobileRoot, "components/bot-avatar.tsx"), "utf8");
|
|
const menu = readFileSync(resolve(mobileRoot, "components/bot-organize-modal.tsx"), "utf8");
|
|
expect(menu).toContain("Silence notifications");
|
|
expect(menu).toContain("Resume notifications");
|
|
expect(index).toContain("muted={!bot.notifyOnFinish}");
|
|
expect(thread).toContain("muted={!currentBot.notifyOnFinish}");
|
|
expect(avatar).toContain('accessibilityLabel="Notifications silenced"');
|
|
expect(avatar).toContain('android="notifications-off"');
|
|
expect(thread.match(/muted=\{/g)).toHaveLength(1);
|
|
});
|
|
});
|