1/// <reference types="node" />
2
3import * as NodeCrypto from "node:crypto";
4
5import { beforeEach, vi } from "vite-plus/test";
6import { describe, expect, it } from "@effect/vitest";
7import Constants from "expo-constants";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import * as Deferred from "effect/Deferred";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
15import { ManagedRelay } from "@t3tools/client-runtime/relay";
16
17import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
18import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
19import { verifyDpopProof } from "@t3tools/shared/dpop";
20import * as Dpop from "../cloud/dpop";
21import * as ManagedRelayLayer from "../cloud/managedRelayLayer";
22import {
23 clearAgentAwarenessRegistrationRecord,
24 loadAgentAwarenessRegistrationRecord,
25 loadOrCreateAgentAwarenessDeviceId,
26 loadPreferences,
27 saveAgentAwarenessRegistrationRecord,
28} from "../../persistence/imperative";
29import type { Preferences } from "../../persistence/mobile-preferences";
30import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
31import {
32 __resetAgentAwarenessRemoteRegistrationForTest,
33 getAgentAwarenessRegistrationStatus,
34 refreshActiveLiveActivityRemoteRegistration,
35 refreshAgentAwarenessRegistration,
36 registerLiveActivityPushToken,
37 releaseAgentAwarenessRelayTokenProvider,
38 setAgentAwarenessRelayTokenProvider,
39 updateAgentAwarenessRegistrationPreferences,
40 type AgentAwarenessRegistrationError,
41} from "./remoteRegistration";
42import * as Notifications from "expo-notifications";
43import { Platform } from "react-native";
44import { configureAndroidAgentNotifications } from "./androidNotifications";
45
46vi.mock("./androidNotifications", () => ({
47 supportsAndroidAgentNotifications: vi.fn(() => true),
48 configureAndroidAgentNotifications: vi.fn(),
49 clearAndroidAgentNotifications: vi.fn(),
50}));
51
52const secureStore = vi.hoisted(() => new Map<string, string>());
53const widgetMocks = vi.hoisted(() => ({
54 dismissEnded: vi.fn(),
55 getInstances: vi.fn(() => []),
56 start: vi.fn(() => ({})),
57}));
58const publishWidgetMock = vi.hoisted(() => vi.fn((_source: unknown) => Promise.resolve()));
59const environmentConfigsMock = vi.hoisted(() => ({
60 configs: new Map<
61 string,
62 { environment: { capabilities: { agentActivityPublishing?: boolean } } }
63 >(),
64}));
65const backgroundRuntime = vi.hoisted(() => ({
66 pending: [] as Array<{
67 readonly operation: unknown;
68 readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void;
69 }>,
70}));
71const appStateMock = vi.hoisted(() => ({
72 listeners: [] as Array<(state: string) => void>,
73}));
74const registrationRecordStore = vi.hoisted(() => ({
75 current: null as {
76 readonly identity: string;
77 readonly signature: string;
78 readonly pushToStartToken?: string;
79 } | null,
80}));
81
82vi.mock("expo-constants", () => ({
83 default: {
84 expoConfig: {
85 version: "1.0.0",
86 extra: {},
87 },
88 },
89}));
90
91vi.mock("expo-widgets", () => ({
92 addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
93}));
94
95vi.mock("../../widgets/publishAgentActivityWidget", () => ({
96 publishAgentActivityWidget: publishWidgetMock,
97}));
98
99vi.mock("./agentLiveActivity", () => ({
100 dismissEndedAgentLiveActivities: widgetMocks.dismissEnded,
101 getAgentLiveActivities: widgetMocks.getInstances,
102 startAgentLiveActivity: widgetMocks.start,
103}));
104
105// Avoid importing the native connection stack; this gate only needs configs.
106vi.mock("../../state/atom-registry", () => ({
107 appAtomRegistry: {
108 get: () => environmentConfigsMock.configs,
109 },
110}));
111
112vi.mock("../../state/server", () => ({
113 environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
114}));
115
116vi.mock("expo-notifications", () => ({
117 AndroidImportance: { NONE: 0, HIGH: 4 },
118 getNotificationChannelAsync: vi.fn(() => Promise.resolve(null)),
119 addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
120 getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
121 getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })),
122}));
123
124vi.mock("expo-crypto", () => ({
125 CryptoDigestAlgorithm: {
126 SHA1: "SHA-1",
127 SHA256: "SHA-256",
128 SHA384: "SHA-384",
129 SHA512: "SHA-512",
130 },
131 getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),
132 getRandomBytesAsync: (byteCount: number) =>
133 Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),
134 digest: (algorithm: string, data: unknown) => {
135 if (!(data instanceof Uint8Array)) {
136 return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));
137 }
138 return Promise.resolve(
139 new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,
140 );
141 },
142}));
143
144vi.mock("expo", async () => {
145 const { createNodeDpopKeysModule } = await import("../cloud/dpop.testing");
146 const dpopKeys = createNodeDpopKeysModule();
147 return { requireNativeModule: () => dpopKeys };
148});
149
150vi.mock("expo-secure-store", () => ({
151 getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),
152 setItemAsync: (key: string, value: string) => {
153 secureStore.set(key, value);
154 return Promise.resolve();
155 },
156}));
157
158vi.mock("react-native", () => ({
159 Platform: {
160 get OS() {
161 return "ios";
162 },
163 get Version() {
164 return "18.0";
165 },
166 },
167 AppState: {
168 addEventListener: (_event: string, listener: (state: string) => void) => {
169 appStateMock.listeners.push(listener);
170 return {
171 remove: () => {
172 const index = appStateMock.listeners.indexOf(listener);
173 if (index >= 0) {
174 appStateMock.listeners.splice(index, 1);
175 }
176 },
177 };
178 },
179 },
180}));
181
182vi.mock("../../lib/runtime", () => ({
183 runtime: {
184 runPromiseExit: (operation: unknown) =>
185 new Promise((resolve) => {
186 backgroundRuntime.pending.push({ operation, resolve });
187 }),
188 },
189}));
190
191vi.mock("../../persistence/imperative", () => ({
192 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
193 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
194 loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })),
195 loadAgentAwarenessRegistrationRecord: vi.fn(() =>
196 Promise.resolve(registrationRecordStore.current),
197 ),
198 saveAgentAwarenessRegistrationRecord: vi.fn((record: { identity: string; signature: string }) => {
199 registrationRecordStore.current = record;
200 return Promise.resolve();
201 }),
202 clearAgentAwarenessRegistrationRecord: vi.fn(() => {
203 registrationRecordStore.current = null;
204 return Promise.resolve();
205 }),
206}));
207
208function proofIat(proof: string): number {
209 const payload = proof.split(".")[1];
210 if (!payload) {
211 throw new Error("Missing DPoP payload.");
212 }
213 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
214 readonly iat: number;
215 };
216 return decoded.iat;
217}
218
219const layerRelayTest = ManagedRelayLayer.layer("https://relay.example.test").pipe(
220 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)),
221);
222
223const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")(
224 function* () {
225 let idlePasses = 0;
226 // oxlint-disable-next-line t3code/no-polling-loop-in-tests -- This harness drains mocked runtime jobs and their promise continuations, not external I/O or elapsed time.
227 for (;;) {
228 yield* Effect.promise(() => Promise.resolve());
229 const pending = backgroundRuntime.pending.shift();
230 if (!pending) {
231 idlePasses++;
232 if (idlePasses >= 3) {
233 return;
234 }
235 continue;
236 }
237 idlePasses = 0;
238 const exit = yield* Effect.exit(
239 pending.operation as Effect.Effect<
240 unknown,
241 AgentAwarenessRegistrationError,
242 ManagedRelay.ManagedRelayClient
243 >,
244 );
245 yield* Effect.sync(() => {
246 pending.resolve(exit);
247 });
248 }
249 },
250);
251
252describe("makeRelayDeviceRegistrationRequest", () => {
253 beforeEach(() => {
254 vi.restoreAllMocks();
255 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
256 type: "ios",
257 data: "apns-token",
258 });
259 vi.mocked(Notifications.getPermissionsAsync).mockReset();
260 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
261 granted: true,
262 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
263 vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue(null);
264 vi.unstubAllGlobals();
265 vi.stubGlobal("__DEV__", false);
266 secureStore.clear();
267 backgroundRuntime.pending.length = 0;
268 Constants.expoConfig!.extra = {};
269 __resetAgentAwarenessRemoteRegistrationForTest();
270 appStateMock.listeners.length = 0;
271 registrationRecordStore.current = null;
272 vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
273 vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear();
274 vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear();
275 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
276 vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false });
277 widgetMocks.getInstances.mockReset();
278 widgetMocks.getInstances.mockReturnValue([]);
279 widgetMocks.start.mockClear();
280 publishWidgetMock.mockClear();
281 widgetMocks.dismissEnded.mockReset();
282 environmentConfigsMock.configs.clear();
283 });
284
285 it("disables push features in Personal Team relay registrations", () => {
286 Constants.expoConfig!.extra = { iosPersonalTeamBuild: true };
287
288 expect(
289 makeRelayDeviceRegistrationRequest({
290 deviceId: "device-1",
291 label: "Julius's iPhone",
292 iosMajorVersion: 18,
293 appVersion: "1.0.0",
294 pushToken: "apns-token",
295 pushToStartToken: "push-to-start-token",
296 notificationsEnabled: true,
297 preferences: {},
298 }).preferences,
299 ).toMatchObject({
300 liveActivitiesEnabled: false,
301 notificationsEnabled: false,
302 });
303 });
304
305 it.effect("registers one push-token listener per Live Activity across wrappers", () => {
306 const addPushTokenListener = vi.fn();
307 // getInstances() returns a new wrapper for the same native activity each call.
308 const wrapper = () => ({
309 getId: () => "activity-1",
310 getPushToken: vi.fn(() => Promise.resolve(null)),
311 addPushTokenListener,
312 });
313
314 return Effect.gen(function* () {
315 expect(yield* registerLiveActivityPushToken({ activity: wrapper() as never })).toBe(false);
316 expect(yield* registerLiveActivityPushToken({ activity: wrapper() as never })).toBe(false);
317
318 expect(addPushTokenListener).toHaveBeenCalledTimes(1);
319 }).pipe(Effect.provide(layerRelayTest));
320 });
321
322 it.effect("registers a Live Activity push token that rotates after registration", () => {
323 const registeredTokens: Array<string> = [];
324 vi.stubGlobal("fetch", async (request: RequestInfo | URL, init?: RequestInit) => {
325 const url = request instanceof Request ? request.url : String(request);
326 if (url.endsWith("/v1/client/dpop-token")) {
327 return Response.json({
328 access_token: "relay-dpop-token",
329 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
330 token_type: "DPoP",
331 expires_in: 300,
332 scope: "mobile:registration",
333 });
334 }
335 if (url.endsWith("/v1/mobile/live-activities")) {
336 const body = request instanceof Request ? await request.text() : String(init?.body);
337 registeredTokens.push(
338 (JSON.parse(body) as { readonly activityPushToken: string }).activityPushToken,
339 );
340 }
341 return Response.json({ ok: true });
342 });
343 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
344 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
345 let tokenListener: ((event: { readonly pushToken: string }) => void) | undefined;
346 const activity = {
347 getId: () => "activity-1",
348 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
349 addPushTokenListener: vi.fn((listener: typeof tokenListener) => {
350 tokenListener = listener;
351 return { remove: vi.fn() };
352 }),
353 };
354
355 return Effect.gen(function* () {
356 yield* runBackgroundOperations();
357 expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(true);
358
359 tokenListener?.({ pushToken: "rotated-activity-token" });
360 yield* runBackgroundOperations();
361
362 expect(registeredTokens).toEqual(["activity-token", "rotated-activity-token"]);
363 }).pipe(
364 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
365 Effect.provide(layerRelayTest),
366 );
367 });
368
369 it.effect("registers a rotated Live Activity token only after an older registration", () => {
370 const arrivals: Array<string> = [];
371 let openOldRequest: (() => void) | undefined;
372 const oldRequestGate = new Promise<void>((resolve) => {
373 openOldRequest = resolve;
374 });
375 let markOldRequestArrived: (() => void) | undefined;
376 const oldRequestArrived = new Promise<void>((resolve) => {
377 markOldRequestArrived = resolve;
378 });
379 vi.stubGlobal("fetch", async (request: RequestInfo | URL, init?: RequestInit) => {
380 const url = request instanceof Request ? request.url : String(request);
381 if (url.endsWith("/v1/client/dpop-token")) {
382 return Response.json({
383 access_token: "relay-dpop-token",
384 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
385 token_type: "DPoP",
386 expires_in: 300,
387 scope: "mobile:registration",
388 });
389 }
390 if (url.endsWith("/v1/mobile/live-activities")) {
391 const body = request instanceof Request ? await request.text() : String(init?.body);
392 const token = (JSON.parse(body) as { readonly activityPushToken: string })
393 .activityPushToken;
394 arrivals.push(token);
395 if (token === "activity-token") {
396 markOldRequestArrived?.();
397 await oldRequestGate;
398 }
399 }
400 return Response.json({ ok: true });
401 });
402 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
403 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
404 let tokenListener: ((event: { readonly pushToken: string }) => void) | undefined;
405 const activity = {
406 getId: () => "activity-1",
407 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
408 addPushTokenListener: vi.fn((listener: typeof tokenListener) => {
409 tokenListener = listener;
410 return { remove: vi.fn() };
411 }),
412 };
413
414 return Effect.gen(function* () {
415 yield* runBackgroundOperations();
416 const lookup = yield* Effect.forkChild(
417 registerLiveActivityPushToken({ activity: activity as never }),
418 );
419 yield* Effect.promise(() => oldRequestArrived);
420
421 tokenListener?.({ pushToken: "rotated-activity-token" });
422 const deviceIdLookups = vi.mocked(loadOrCreateAgentAwarenessDeviceId).mock.calls.length;
423 const rotated = backgroundRuntime.pending.shift()!;
424 // Starting immediately runs the listener's registration until it suspends: on the
425 // activity lock, or, without one, at its device-id lookup.
426 const rotatedRegistration = yield* Effect.forkChild(
427 Effect.exit(
428 rotated.operation as Effect.Effect<
429 unknown,
430 AgentAwarenessRegistrationError,
431 ManagedRelay.ManagedRelayClient
432 >,
433 ),
434 { startImmediately: true },
435 );
436 expect(vi.mocked(loadOrCreateAgentAwarenessDeviceId).mock.calls).toHaveLength(
437 deviceIdLookups,
438 );
439 expect(arrivals).toEqual(["activity-token"]);
440
441 openOldRequest?.();
442 yield* Fiber.join(lookup);
443 rotated.resolve(yield* Fiber.join(rotatedRegistration));
444 expect(arrivals).toEqual(["activity-token", "rotated-activity-token"]);
445 }).pipe(
446 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
447 Effect.provide(layerRelayTest),
448 );
449 });
450
451 it.effect("still registers the active card when ended-card cleanup throws", () => {
452 const registrations: unknown[] = [];
453 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
454 const request = new Request(input, init);
455 if (request.url.endsWith("/v1/client/dpop-token")) {
456 return Response.json({
457 access_token: "relay-dpop-token",
458 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
459 token_type: "DPoP",
460 expires_in: 300,
461 scope: "mobile:registration",
462 });
463 }
464 if (request.url.endsWith("/v1/mobile/live-activities")) {
465 registrations.push(await request.json());
466 }
467 return Response.json({ ok: true });
468 });
469 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
470 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
471 const activity = {
472 getId: () => "activity-1",
473 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
474 addPushTokenListener: vi.fn(),
475 end: vi.fn(),
476 };
477
478 return Effect.gen(function* () {
479 yield* runBackgroundOperations();
480 widgetMocks.dismissEnded.mockReset();
481 widgetMocks.dismissEnded.mockImplementation(() => {
482 throw new Error("Cannot find function dismissEnded");
483 });
484 widgetMocks.getInstances.mockReturnValue([activity] as never);
485
486 for (const listener of appStateMock.listeners) listener("active");
487 yield* runBackgroundOperations();
488
489 expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce();
490 expect(registrations).toEqual([
491 { deviceId: "device-1", activityPushToken: "activity-token" },
492 ]);
493 expect(activity.end).not.toHaveBeenCalled();
494 expect(widgetMocks.start).not.toHaveBeenCalled();
495 }).pipe(
496 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
497 Effect.provide(layerRelayTest),
498 );
499 });
500
501 it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => {
502 const end = vi.fn(() => Promise.resolve());
503 const activity = {
504 getId: () => "activity-1",
505 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
506 addPushTokenListener: vi.fn(),
507 end,
508 };
509 widgetMocks.getInstances.mockReturnValue([activity] as never);
510 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
511 expect(appStateMock.listeners).toHaveLength(1);
512
513 setAgentAwarenessRelayTokenProvider(null);
514
515 expect(end).toHaveBeenCalledWith("immediate");
516 expect(widgetMocks.dismissEnded).toHaveBeenCalled();
517 expect(appStateMock.listeners).toHaveLength(0);
518 });
519
520 it.effect("registers the APNs device when cloud auth becomes available", () => {
521 const fetchMock = vi.fn((request: RequestInfo | URL) => {
522 const url = request instanceof Request ? request.url : String(request);
523 return Promise.resolve(
524 Response.json(
525 url.endsWith("/v1/client/dpop-token")
526 ? {
527 access_token: "relay-dpop-token",
528 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
529 token_type: "DPoP",
530 expires_in: 300,
531 scope: "mobile:registration",
532 }
533 : { ok: true },
534 ),
535 );
536 });
537 vi.stubGlobal("fetch", fetchMock);
538 Constants.expoConfig!.extra = {
539 relay: {
540 url: "https://relay.example.test/",
541 },
542 };
543
544 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
545
546 return Effect.gen(function* () {
547 yield* runBackgroundOperations();
548
549 const requestUrl = (request: unknown) =>
550 request instanceof Request ? request.url : String(request);
551 const deviceCalls = fetchMock.mock.calls.filter(([request]) =>
552 requestUrl(request).endsWith("/v1/mobile/devices"),
553 );
554 expect(deviceCalls).toHaveLength(1);
555 const [request, init] = deviceCalls[0] as unknown as [unknown, RequestInit | undefined];
556 const url = requestUrl(request);
557 const method = request instanceof Request ? request.method : init?.method;
558 const headers = request instanceof Request ? request.headers : new Headers(init?.headers);
559 const dpop = headers.get("dpop");
560 expect(url).toBe("https://relay.example.test/v1/mobile/devices");
561 expect(method).toBe("POST");
562 expect(headers.get("authorization")).toBe("DPoP relay-dpop-token");
563 expect(dpop).toEqual(expect.any(String));
564 if (!dpop) {
565 throw new Error("Missing DPoP header.");
566 }
567 expect(
568 verifyDpopProof({
569 proof: dpop,
570 method: "POST",
571 url: "https://relay.example.test/v1/mobile/devices",
572 expectedAccessToken: "relay-dpop-token",
573 nowEpochSeconds: proofIat(dpop),
574 }),
575 ).toMatchObject({ ok: true });
576 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
577 }).pipe(Effect.provide(layerRelayTest));
578 });
579
580 it.effect("keeps a registered status when a later refresh fails", () => {
581 Constants.expoConfig!.extra = {
582 relay: {
583 url: "https://relay.example.test/",
584 },
585 };
586 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
587
588 return Effect.gen(function* () {
589 yield* runBackgroundOperations();
590 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
591
592 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
593 new Error("transient failure"),
594 );
595 yield* refreshAgentAwarenessRegistration();
596 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
597 }).pipe(Effect.provide(layerRelayTest));
598 });
599
600 it.effect("does not re-register the same account when nothing has changed", () => {
601 Constants.expoConfig!.extra = {
602 relay: {
603 url: "https://relay.example.test/",
604 },
605 };
606 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
607
608 return Effect.gen(function* () {
609 yield* refreshAgentAwarenessRegistration();
610 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
611 expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1);
612 expect(registrationRecordStore.current).not.toBeNull();
613
614 vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear();
615 yield* refreshAgentAwarenessRegistration();
616 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
617 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
618 }).pipe(Effect.provide(layerRelayTest));
619 });
620
621 it.effect("continues queued device registration after a failed auth lookup", () => {
622 Constants.expoConfig!.extra = {
623 relay: {
624 url: "https://relay.example.test/",
625 },
626 };
627
628 // Sign-in first reads the widget snapshot, then registers the device; fail
629 // both lookups so the queued registration is the one that must recover.
630 const tokenProvider = vi
631 .fn<() => Promise<string | null>>()
632 .mockRejectedValueOnce(new Error("auth unavailable"))
633 .mockRejectedValueOnce(new Error("auth unavailable"))
634 .mockResolvedValue("clerk-token-user-a");
635 setAgentAwarenessRelayTokenProvider(tokenProvider);
636 const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0];
637 expect(tokenListener).toBeDefined();
638 tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never);
639
640 return Effect.gen(function* () {
641 yield* runBackgroundOperations();
642
643 expect(backgroundRuntime.pending).toHaveLength(0);
644 expect(tokenProvider).toHaveBeenCalledTimes(3);
645 }).pipe(Effect.provide(layerRelayTest));
646 });
647
648 it.effect.each(["ios", "android"] as const)(
649 "does not enable %s notifications when a token rotates after permission is revoked",
650 (os) => {
651 vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true });
652 vi.spyOn(Platform, "OS", "get").mockReturnValue(os);
653 vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36);
654 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
655 type: os,
656 data: "initial",
657 });
658 const registrations: unknown[] = [];
659 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
660 const request = new Request(input, init);
661 if (request.url.endsWith("/v1/client/dpop-token")) {
662 return Response.json({
663 access_token: "dpop",
664 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
665 token_type: "DPoP",
666 expires_in: 300,
667 scope: "mobile:registration",
668 });
669 }
670 registrations.push(await request.json());
671 return Response.json({ ok: true });
672 });
673 Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };
674 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
675 return Effect.gen(function* () {
676 yield* runBackgroundOperations();
677 expect(registrations.at(-1)).toMatchObject({
678 preferences: { notificationsEnabled: true, liveActivitiesEnabled: true },
679 });
680 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
681 granted: false,
682 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
683 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
684 listener({ type: os, data: "rotated" });
685 yield* runBackgroundOperations();
686 expect(registrations.at(-1)).toMatchObject({
687 preferences: { notificationsEnabled: false, liveActivitiesEnabled: os === "ios" },
688 });
689 if (os === "android") {
690 expect(registrations.at(-1)).toMatchObject({ pushToken: "rotated" });
691 vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
692 granted: false,
693 } as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
694 yield* refreshAgentAwarenessRegistration();
695 expect(registrations.at(-1)).toMatchObject({
696 pushToken: "initial",
697 preferences: { notificationsEnabled: false, liveActivitiesEnabled: false },
698 });
699 } else {
700 expect(registrations.at(-1)).not.toHaveProperty("pushToken");
701 }
702 }).pipe(
703 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
704 Effect.provide(
705 ManagedRelayLayer.layer("https://permission-relay.example.test").pipe(
706 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, Dpop.layer)),
707 ),
708 ),
709 );
710 },
711 );
712 it.effect.each(["blocked", "unavailable"] as const)(
713 "keeps Android push delivery registered when the alert channel is %s",
714 (channelState) => {
715 vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true });
716 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
717 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
718 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
719 type: "android",
720 data: "fcm-token",
721 });
722 vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue({
723 id: "agent-alerts",
724 name: "Agent alerts",
725 importance: Notifications.AndroidImportance.NONE,
726 } as Awaited<ReturnType<typeof Notifications.getNotificationChannelAsync>>);
727 if (channelState === "unavailable") {
728 vi.mocked(Notifications.getNotificationChannelAsync).mockRejectedValue(
729 new Error("Android channel lookup failed"),
730 );
731 }
732 const registrations: unknown[] = [];
733 vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
734 const request = new Request(input, init);
735 if (request.url.endsWith("/v1/client/dpop-token")) {
736 return Response.json({
737 access_token: "dpop",
738 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
739 token_type: "DPoP",
740 expires_in: 300,
741 scope: "mobile:registration",
742 });
743 }
744 registrations.push(await request.json());
745 return Response.json({ ok: true });
746 });
747 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
748 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
749 return Effect.gen(function* () {
750 yield* runBackgroundOperations();
751 expect(registrations.at(-1)).toMatchObject({
752 pushToken: "fcm-token",
753 preferences: { notificationsEnabled: false, liveActivitiesEnabled: true },
754 });
755 expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
756
757 const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
758 listener({ type: "android", data: "rotated-fcm-token" });
759 yield* runBackgroundOperations();
760 expect(registrations.at(-1)).toMatchObject({
761 pushToken: "rotated-fcm-token",
762 preferences: { notificationsEnabled: false, liveActivitiesEnabled: true },
763 });
764
765 vi.mocked(Notifications.getNotificationChannelAsync).mockResolvedValue(null);
766 yield* refreshAgentAwarenessRegistration();
767 expect(registrations.at(-1)).toMatchObject({
768 pushToken: "fcm-token",
769 preferences: { notificationsEnabled: true },
770 });
771 }).pipe(
772 Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
773 Effect.provide(layerRelayTest),
774 );
775 },
776 );
777 it.effect("preserves relay rejection errors with React Native response headers", () => {
778 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
779 vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({
780 type: "android",
781 data: "fcm-token",
782 });
783 const rejectedResponse = new Response("Unsupported device platform", { status: 400 });
784 Object.defineProperty(rejectedResponse.headers, "getSetCookie", { value: undefined });
785 vi.stubGlobal("fetch", (request: RequestInfo | URL) => {
786 const url = request instanceof Request ? request.url : String(request);
787 const response = url.endsWith("/v1/client/dpop-token")
788 ? Response.json({
789 access_token: "relay-dpop-token",
790 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
791 token_type: "DPoP",
792 expires_in: 300,
793 scope: "mobile:registration",
794 })
795 : rejectedResponse;
796 Object.defineProperty(response.headers, "getSetCookie", { value: undefined });
797 return Promise.resolve(response);
798 });
799 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
800 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a");
801
802 return Effect.gen(function* () {
803 // Hermes' compiled error hashing reads the response's cookie getter.
804 const httpResponse = HttpClientResponse.fromWeb(
805 HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"),
806 rejectedResponse,
807 );
808 expect(httpResponse.cookies).toEqual(Cookies.empty);
809 const result = yield* Effect.exit(
810 updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: true }),
811 );
812 expect(Exit.isFailure(result)).toBe(true);
813 if (Exit.isFailure(result)) {
814 expect(Cause.hasDies(result.cause)).toBe(false);
815 expect(Cause.squash(result.cause)).toBeInstanceOf(
816 ManagedRelay.ManagedRelayRequestFailedError,
817 );
818 }
819 expect(getAgentAwarenessRegistrationStatus()).toBe("failed");
820 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
821 }).pipe(Effect.provide(layerRelayTest));
822 });
823
824 it.effect(
825 "does not reconfigure or register a detached Android account from an in-flight operation",
826 () => {
827 vi.spyOn(Platform, "OS", "get").mockReturnValue("android");
828 vi.spyOn(Platform, "Version", "get").mockReturnValue(36);
829 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } };
830 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
831 releaseAgentAwarenessRelayTokenProvider();
832 vi.mocked(configureAndroidAgentNotifications).mockClear();
833 return Effect.gen(function* () {
834 yield* runBackgroundOperations();
835 expect(configureAndroidAgentNotifications).not.toHaveBeenCalled();
836 expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled();
837 }).pipe(Effect.provide(layerRelayTest));
838 },
839 );
840
841 describe("home-screen widget", () => {
842 // These cover the widget path only, so keep the Live Activity from priming.
843 beforeEach(() => {
844 vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences);
845 });
846
847 const aggregate: RelayAgentActivityAggregateState = {
848 title: "T3 Code",
849 subtitle: "Agent work in progress",
850 activeCount: 1,
851 updatedAt: "2026-10-18T10:00:00.000Z",
852 activities: [
853 {
854 environmentId: "env-1" as EnvironmentId,
855 threadId: "thread-1" as ThreadId,
856 projectTitle: "t3code",
857 threadTitle: "Fix the widget",
858 modelTitle: "GPT-5.5",
859 phase: "running",
860 status: "Working",
861 updatedAt: "2026-10-18T10:00:00.000Z",
862 deepLink: "/threads/env-1/thread-1",
863 },
864 ],
865 };
866
867 // The shared fetch-backed relay layer keeps the first test's fetch stub,
868 // so these tests answer the snapshot read through a fake client instead.
869 function fakeRelay(
870 getAgentActivitySnapshot: () => Effect.Effect<
871 { readonly aggregate: typeof aggregate },
872 ManagedRelay.ManagedRelayRequestFailedError
873 >,
874 ) {
875 Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
876 const unexpected = () => Effect.die("Unexpected relay request");
877 return Layer.succeed(
878 ManagedRelay.ManagedRelayClient,
879 ManagedRelay.ManagedRelayClient.of({
880 relayUrl: "https://relay.example.test",
881 listEnvironments: unexpected,
882 listDevices: unexpected,
883 getT3ChatLink: unexpected,
884 createT3ChatLinkRequest: unexpected,
885 unlinkT3Chat: unexpected,
886 createEnvironmentLinkChallenge: unexpected,
887 linkEnvironment: unexpected,
888 unlinkEnvironment: unexpected,
889 getEnvironmentStatus: unexpected,
890 connectEnvironment: unexpected,
891 registerDevice: unexpected,
892 unregisterDevice: unexpected,
893 registerLiveActivity: unexpected,
894 getAgentActivitySnapshot,
895 resetTokenCache: Effect.void,
896 }),
897 );
898 }
899
900 it.effect("registers an existing Live Activity before waiting on the widget read", () => {
901 const order: Array<string> = [];
902 const activity = {
903 getId: () => "activity-1",
904 getPushToken: vi.fn(() => {
905 order.push("push-token");
906 return Promise.resolve(null);
907 }),
908 addPushTokenListener: vi.fn(),
909 };
910 widgetMocks.getInstances.mockReturnValue([activity] as never);
911 const relay = fakeRelay(() =>
912 Effect.sync(() => {
913 order.push("widget-snapshot");
914 return { aggregate };
915 }),
916 );
917 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
918
919 return Effect.gen(function* () {
920 yield* refreshActiveLiveActivityRemoteRegistration();
921
922 expect(order).toEqual(["push-token", "widget-snapshot"]);
923 expect(publishWidgetMock).toHaveBeenLastCalledWith({
924 kind: "snapshot",
925 aggregate,
926 });
927 }).pipe(Effect.provide(relay));
928 });
929
930 it.effect("keeps the newest rows when an older overlapping read finishes last", () =>
931 Effect.gen(function* () {
932 const newer = { ...aggregate, activeCount: 2 };
933 const olderRequested = yield* Deferred.make<void>();
934 const olderResponse = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>();
935 let reads = 0;
936 const relay = fakeRelay(() => {
937 reads++;
938 return reads === 1
939 ? Deferred.succeed(olderRequested, undefined).pipe(
940 Effect.andThen(Deferred.await(olderResponse)),
941 )
942 : Effect.succeed({ aggregate: newer });
943 });
944 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
945
946 const older = yield* Effect.forkChild(
947 refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
948 );
949 yield* Deferred.await(olderRequested);
950 yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay));
951 yield* Deferred.succeed(olderResponse, { aggregate });
952 yield* Fiber.join(older);
953
954 expect(publishWidgetMock).toHaveBeenLastCalledWith({
955 kind: "snapshot",
956 aggregate: newer,
957 });
958 }),
959 );
960
961 it.effect("an obsolete refresh does not discard the next account's read", () =>
962 Effect.gen(function* () {
963 let markTokenRequested = () => {};
964 const tokenRequested = new Promise<void>((resolve) => {
965 markTokenRequested = resolve;
966 });
967 let releaseToken = () => {};
968 widgetMocks.getInstances.mockReturnValueOnce([
969 {
970 getId: () => "activity-a",
971 getPushToken: () => {
972 markTokenRequested();
973 return new Promise<null>((resolve) => {
974 releaseToken = () => resolve(null);
975 });
976 },
977 addPushTokenListener: vi.fn(),
978 },
979 ] as never);
980 const requested = yield* Deferred.make<void>();
981 const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>();
982 const relay = fakeRelay(() =>
983 Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))),
984 );
985 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a");
986
987 const refreshA = yield* Effect.forkChild(
988 refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
989 );
990 yield* Effect.promise(() => tokenRequested);
991 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b");
992 const refreshB = yield* Effect.forkChild(
993 refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
994 );
995 yield* Deferred.await(requested);
996 releaseToken();
997 yield* Fiber.join(refreshA);
998 yield* Deferred.succeed(response, { aggregate });
999 yield* Fiber.join(refreshB);
1000
1001 expect(publishWidgetMock).toHaveBeenLastCalledWith({
1002 kind: "snapshot",
1003 aggregate,
1004 });
1005 }),
1006 );
1007
1008 it.effect("does not prime a Live Activity from a read a newer one replaced", () =>
1009 Effect.gen(function* () {
1010 let markRequested = () => {};
1011 const requested = new Promise<void>((resolve) => {
1012 markRequested = resolve;
1013 });
1014 let resolvePreferences = (_preferences: Preferences) => {};
1015 vi.mocked(loadPreferences).mockImplementationOnce(() => {
1016 markRequested();
1017 return new Promise((resolve) => {
1018 resolvePreferences = resolve;
1019 });
1020 });
1021 const idle = { ...aggregate, activeCount: 0, activities: [] };
1022 let reads = 0;
1023 const relay = fakeRelay(() =>
1024 Effect.succeed({ aggregate: ++reads === 1 ? aggregate : idle }),
1025 );
1026 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
1027
1028 const older = yield* Effect.forkChild(
1029 refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
1030 );
1031 yield* Effect.promise(() => requested);
1032 yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay));
1033 resolvePreferences({ liveActivitiesEnabled: true } as Preferences);
1034 yield* Fiber.join(older);
1035
1036 expect(widgetMocks.start).not.toHaveBeenCalled();
1037 }),
1038 );
1039
1040 it.effect("drops a snapshot that arrives after a sign-out, even on re-sign-in", () =>
1041 Effect.gen(function* () {
1042 const requested = yield* Deferred.make<void>();
1043 const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>();
1044 const relay = fakeRelay(() =>
1045 Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))),
1046 );
1047 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a");
1048
1049 const refresh = yield* Effect.forkChild(
1050 refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
1051 );
1052 yield* Deferred.await(requested);
1053 setAgentAwarenessRelayTokenProvider(null);
1054 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a");
1055 publishWidgetMock.mockClear();
1056 yield* Deferred.succeed(response, { aggregate });
1057 yield* Fiber.join(refresh);
1058
1059 expect(publishWidgetMock).not.toHaveBeenCalled();
1060 }),
1061 );
1062
1063 it.effect("does not prime a Live Activity after sign-out during the preference read", () =>
1064 Effect.gen(function* () {
1065 let markRequested = () => {};
1066 const requested = new Promise<void>((resolve) => {
1067 markRequested = resolve;
1068 });
1069 let resolvePreferences = (_preferences: Preferences) => {};
1070 vi.mocked(loadPreferences).mockImplementationOnce(() => {
1071 markRequested();
1072 return new Promise((resolve) => {
1073 resolvePreferences = resolve;
1074 });
1075 });
1076 const relay = fakeRelay(() => Effect.succeed({ aggregate }));
1077 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");
1078
1079 const refresh = yield* Effect.forkChild(
1080 refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
1081 );
1082 yield* Effect.promise(() => requested);
1083 setAgentAwarenessRelayTokenProvider(null);
1084 resolvePreferences({ liveActivitiesEnabled: true } as Preferences);
1085 yield* Fiber.join(refresh);
1086
1087 expect(widgetMocks.start).not.toHaveBeenCalled();
1088 }),
1089 );
1090 });
1091});