@teardown/react-native 2.0.0 → 2.0.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/README.md +33 -19
- package/docs/01-getting-started.mdx +147 -0
- package/docs/02-core-concepts.mdx +188 -0
- package/docs/03-identity.mdx +301 -0
- package/docs/04-force-updates.mdx +339 -0
- package/docs/05-device-info.mdx +324 -0
- package/docs/06-logging.mdx +345 -0
- package/docs/06-storage.mdx +349 -0
- package/docs/07-api-reference.mdx +472 -0
- package/docs/07-logging.mdx +345 -0
- package/docs/08-api-reference.mdx +472 -0
- package/docs/08-hooks-reference.mdx +476 -0
- package/docs/09-advanced.mdx +563 -0
- package/docs/09-hooks-reference.mdx +476 -0
- package/docs/10-advanced.mdx +563 -0
- package/package.json +46 -18
- package/src/clients/api/api.client.ts +29 -4
- package/src/clients/device/{expo-adapter.ts → adapters/basic.adapter.ts} +2 -40
- package/src/clients/device/{device.adpater-interface.ts → adapters/device.adpater-interface.ts} +1 -1
- package/src/clients/device/adapters/expo.adapter.ts +90 -0
- package/src/clients/device/device.client.test.ts +1 -6
- package/src/clients/device/device.client.ts +5 -1
- package/src/clients/device/index.ts +1 -1
- package/src/clients/force-update/force-update.client.test.ts +244 -13
- package/src/clients/force-update/force-update.client.ts +71 -11
- package/src/clients/identity/identity.client.test.ts +888 -223
- package/src/clients/identity/identity.client.ts +59 -14
- package/src/clients/storage/adapters/async-storage.adapter.ts +81 -0
- package/src/clients/storage/{mmkv-adapter.ts → adapters/mmkv.adapter.ts} +7 -10
- package/src/clients/storage/adapters/storage.adpater-interface.ts +30 -0
- package/src/clients/storage/index.ts +2 -1
- package/src/clients/storage/storage.client.ts +9 -20
- package/src/clients/utils/utils.client.ts +1 -57
- package/src/exports/adapters/async-storage.ts +1 -0
- package/src/exports/adapters/expo.ts +1 -0
- package/src/exports/adapters/mmkv.ts +1 -0
- package/src/hooks/use-force-update.ts +12 -3
- package/src/hooks/use-session.ts +7 -4
- package/src/teardown.core.ts +16 -6
- package/src/exports/expo.ts +0 -1
- package/src/exports/mmkv.ts +0 -1
|
@@ -6,11 +6,10 @@ import type {
|
|
|
6
6
|
} from "@teardown/schemas";
|
|
7
7
|
import * as Application from "expo-application";
|
|
8
8
|
import * as Device from "expo-device";
|
|
9
|
-
import * as Notifications from "expo-notifications";
|
|
10
9
|
import { Platform } from "react-native";
|
|
11
10
|
|
|
12
11
|
import { DeviceInfoAdapter } from "./device.adpater-interface";
|
|
13
|
-
import { DevicePlatformEnum, NotificationPlatformEnum } from "
|
|
12
|
+
import { DevicePlatformEnum, NotificationPlatformEnum } from "../device.client";
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* Maps expo-device DeviceType to a string representation
|
|
@@ -50,13 +49,6 @@ function mapPlatform(platform: typeof Platform.OS): DevicePlatformEnum {
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
/**
|
|
54
|
-
* Determines the notification platform based on the device platform
|
|
55
|
-
*/
|
|
56
|
-
function getNotificationPlatform(): NotificationPlatformEnum {
|
|
57
|
-
return NotificationPlatformEnum.EXPO;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
52
|
export class ExpoDeviceAdapter extends DeviceInfoAdapter {
|
|
61
53
|
get applicationInfo(): ApplicationInfo {
|
|
62
54
|
return {
|
|
@@ -85,44 +77,14 @@ export class ExpoDeviceAdapter extends DeviceInfoAdapter {
|
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
get notificationsInfo(): NotificationsInfo {
|
|
88
|
-
// Note: This returns a synchronous snapshot.
|
|
89
|
-
// For accurate permission status, use getNotificationsInfoAsync()
|
|
90
80
|
return {
|
|
91
81
|
push: {
|
|
92
82
|
enabled: false,
|
|
93
83
|
granted: false,
|
|
94
84
|
token: null,
|
|
95
|
-
platform:
|
|
85
|
+
platform: NotificationPlatformEnum.EXPO,
|
|
96
86
|
},
|
|
97
87
|
};
|
|
98
88
|
}
|
|
99
89
|
|
|
100
|
-
/**
|
|
101
|
-
* Async method to get accurate notification permissions and token.
|
|
102
|
-
* Use this when you need real-time notification status.
|
|
103
|
-
*/
|
|
104
|
-
async getNotificationsInfoAsync(): Promise<NotificationsInfo> {
|
|
105
|
-
const { status: existingStatus } =
|
|
106
|
-
await Notifications.getPermissionsAsync();
|
|
107
|
-
const granted = existingStatus === "granted";
|
|
108
|
-
|
|
109
|
-
let token: string | null = null;
|
|
110
|
-
if (granted) {
|
|
111
|
-
try {
|
|
112
|
-
const tokenData = await Notifications.getExpoPushTokenAsync();
|
|
113
|
-
token = tokenData.data;
|
|
114
|
-
} catch {
|
|
115
|
-
// Token retrieval failed, keep as null
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
push: {
|
|
121
|
-
enabled: granted,
|
|
122
|
-
granted,
|
|
123
|
-
token,
|
|
124
|
-
platform: getNotificationPlatform(),
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
90
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApplicationInfo,
|
|
3
|
+
HardwareInfo,
|
|
4
|
+
NotificationsInfo,
|
|
5
|
+
OSInfo,
|
|
6
|
+
} from "@teardown/schemas";
|
|
7
|
+
import * as Application from "expo-application";
|
|
8
|
+
import * as Device from "expo-device";
|
|
9
|
+
import { Platform } from "react-native";
|
|
10
|
+
|
|
11
|
+
import { DeviceInfoAdapter } from "./device.adpater-interface";
|
|
12
|
+
import { DevicePlatformEnum, NotificationPlatformEnum } from "../device.client";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Maps expo-device DeviceType to a string representation
|
|
16
|
+
*/
|
|
17
|
+
function mapDeviceType(deviceType: Device.DeviceType | null): string {
|
|
18
|
+
switch (deviceType) {
|
|
19
|
+
case Device.DeviceType.PHONE:
|
|
20
|
+
return "phone";
|
|
21
|
+
case Device.DeviceType.TABLET:
|
|
22
|
+
return "tablet";
|
|
23
|
+
case Device.DeviceType.DESKTOP:
|
|
24
|
+
return "desktop";
|
|
25
|
+
case Device.DeviceType.TV:
|
|
26
|
+
return "tv";
|
|
27
|
+
default:
|
|
28
|
+
return "unknown";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Maps React Native Platform.OS to DevicePlatformEnum
|
|
34
|
+
*/
|
|
35
|
+
function mapPlatform(platform: typeof Platform.OS): DevicePlatformEnum {
|
|
36
|
+
switch (platform) {
|
|
37
|
+
case "ios":
|
|
38
|
+
return DevicePlatformEnum.IOS;
|
|
39
|
+
case "android":
|
|
40
|
+
return DevicePlatformEnum.ANDROID;
|
|
41
|
+
case "web":
|
|
42
|
+
return DevicePlatformEnum.WEB;
|
|
43
|
+
case "macos":
|
|
44
|
+
return DevicePlatformEnum.MACOS;
|
|
45
|
+
case "windows":
|
|
46
|
+
return DevicePlatformEnum.WINDOWS;
|
|
47
|
+
default:
|
|
48
|
+
return DevicePlatformEnum.UNKNOWN;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ExpoDeviceAdapter extends DeviceInfoAdapter {
|
|
53
|
+
get applicationInfo(): ApplicationInfo {
|
|
54
|
+
return {
|
|
55
|
+
version: Application.nativeApplicationVersion ?? "0.0.0",
|
|
56
|
+
build_number: Number.parseInt(
|
|
57
|
+
Application.nativeBuildVersion ?? "0",
|
|
58
|
+
10
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get hardwareInfo(): HardwareInfo {
|
|
64
|
+
return {
|
|
65
|
+
device_name: Device.deviceName ?? "Unknown Device",
|
|
66
|
+
device_brand: Device.brand ?? "Unknown Brand",
|
|
67
|
+
device_type: mapDeviceType(Device.deviceType),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get osInfo(): OSInfo {
|
|
72
|
+
return {
|
|
73
|
+
platform: mapPlatform(Platform.OS),
|
|
74
|
+
name: Device.osName ?? Platform.OS,
|
|
75
|
+
version: Device.osVersion ?? "0.0.0",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get notificationsInfo(): NotificationsInfo {
|
|
80
|
+
return {
|
|
81
|
+
push: {
|
|
82
|
+
enabled: false,
|
|
83
|
+
granted: false,
|
|
84
|
+
token: null,
|
|
85
|
+
platform: NotificationPlatformEnum.EXPO,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DeviceInfo } from "@teardown/schemas";
|
|
2
2
|
import { describe, expect, test } from "bun:test";
|
|
3
|
-
import type { DeviceInfoAdapter } from "./device.adpater-interface";
|
|
3
|
+
import type { DeviceInfoAdapter } from "./adapters/device.adpater-interface";
|
|
4
4
|
import { DeviceClient } from "./device.client";
|
|
5
5
|
|
|
6
6
|
function createMockLoggingClient() {
|
|
@@ -51,10 +51,6 @@ function createMockDeviceAdapter(): DeviceInfoAdapter {
|
|
|
51
51
|
name: "iOS",
|
|
52
52
|
version: "17.0",
|
|
53
53
|
},
|
|
54
|
-
notifications: {
|
|
55
|
-
push_token: null,
|
|
56
|
-
platform: null,
|
|
57
|
-
},
|
|
58
54
|
update: null,
|
|
59
55
|
};
|
|
60
56
|
|
|
@@ -62,7 +58,6 @@ function createMockDeviceAdapter(): DeviceInfoAdapter {
|
|
|
62
58
|
applicationInfo: mockDeviceInfo.application,
|
|
63
59
|
hardwareInfo: mockDeviceInfo.hardware,
|
|
64
60
|
osInfo: mockDeviceInfo.os,
|
|
65
|
-
notificationsInfo: mockDeviceInfo.notifications,
|
|
66
61
|
getDeviceInfo: async () => mockDeviceInfo,
|
|
67
62
|
} as DeviceInfoAdapter;
|
|
68
63
|
}
|
|
@@ -2,7 +2,7 @@ import type { DeviceInfo } from "@teardown/schemas";
|
|
|
2
2
|
import type { Logger, LoggingClient } from "../logging/";
|
|
3
3
|
import type { StorageClient, SupportedStorage } from "../storage";
|
|
4
4
|
import type { UtilsClient } from "../utils/utils.client";
|
|
5
|
-
import type { DeviceInfoAdapter } from "./device.adpater-interface";
|
|
5
|
+
import type { DeviceInfoAdapter } from "./adapters/device.adpater-interface";
|
|
6
6
|
|
|
7
7
|
// TODO: sort out why importing these enuims from schemas is not working - @teardown/schemas
|
|
8
8
|
export enum NotificationPlatformEnum {
|
|
@@ -54,11 +54,13 @@ export class DeviceClient {
|
|
|
54
54
|
|
|
55
55
|
this.logger.debug("Getting device ID");
|
|
56
56
|
const deviceId = this.storage.getItem("deviceId");
|
|
57
|
+
this.logger.debug(`Device ID found in storage: ${deviceId}`);
|
|
57
58
|
if (deviceId) {
|
|
58
59
|
return deviceId;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const newDeviceId = await this.utils.generateRandomUUID();
|
|
63
|
+
await this.storage.setItem("deviceId", newDeviceId);
|
|
62
64
|
|
|
63
65
|
return newDeviceId;
|
|
64
66
|
}
|
|
@@ -66,4 +68,6 @@ export class DeviceClient {
|
|
|
66
68
|
async getDeviceInfo(): Promise<DeviceInfo> {
|
|
67
69
|
return this.options.adapter.getDeviceInfo();
|
|
68
70
|
}
|
|
71
|
+
|
|
72
|
+
|
|
69
73
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
|
2
2
|
import { EventEmitter } from "eventemitter3";
|
|
3
|
+
import { ForceUpdateClient, IdentifyVersionStatusEnum, VERSION_STATUS_STORAGE_KEY } from "./force-update.client";
|
|
3
4
|
|
|
4
5
|
// Must mock react-native BEFORE any imports that use it
|
|
5
6
|
const mockAppStateListeners: ((state: string) => void)[] = [];
|
|
@@ -12,16 +13,15 @@ mock.module("react-native", () => ({
|
|
|
12
13
|
},
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
|
-
// Import after mock
|
|
16
|
-
const { ForceUpdateClient, IdentifyVersionStatusEnum } = await import("./force-update.client");
|
|
17
16
|
type IdentifyState = import("../identity").IdentifyState;
|
|
18
17
|
type IdentifyStateChangeEvents = import("../identity").IdentifyStateChangeEvents;
|
|
19
18
|
type VersionStatus = import("./force-update.client").VersionStatus;
|
|
20
19
|
|
|
21
|
-
function createMockIdentityClient() {
|
|
20
|
+
function createMockIdentityClient(initialState?: IdentifyState) {
|
|
22
21
|
const emitter = new EventEmitter<IdentifyStateChangeEvents>();
|
|
23
22
|
let identifyCallCount = 0;
|
|
24
|
-
let
|
|
23
|
+
let currentState: IdentifyState = initialState ?? { type: "unidentified" };
|
|
24
|
+
let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } | null = {
|
|
25
25
|
success: true,
|
|
26
26
|
data: { version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE } },
|
|
27
27
|
};
|
|
@@ -32,14 +32,20 @@ function createMockIdentityClient() {
|
|
|
32
32
|
emitter.addListener("IDENTIFY_STATE_CHANGED", listener);
|
|
33
33
|
return () => emitter.removeListener("IDENTIFY_STATE_CHANGED", listener);
|
|
34
34
|
},
|
|
35
|
+
getIdentifyState: () => currentState,
|
|
35
36
|
identify: async () => {
|
|
36
37
|
identifyCallCount++;
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
if (nextIdentifyResult === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
currentState = { type: "identifying" };
|
|
42
|
+
emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
|
|
43
|
+
currentState = {
|
|
39
44
|
type: "identified",
|
|
40
45
|
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
41
46
|
version_info: { status: nextIdentifyResult.data?.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
42
|
-
}
|
|
47
|
+
};
|
|
48
|
+
emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
|
|
43
49
|
return nextIdentifyResult;
|
|
44
50
|
},
|
|
45
51
|
getIdentifyCallCount: () => identifyCallCount,
|
|
@@ -52,10 +58,10 @@ function createMockIdentityClient() {
|
|
|
52
58
|
function createMockLoggingClient() {
|
|
53
59
|
return {
|
|
54
60
|
createLogger: () => ({
|
|
55
|
-
info: () => {},
|
|
56
|
-
warn: () => {},
|
|
57
|
-
error: () => {},
|
|
58
|
-
debug: () => {},
|
|
61
|
+
info: () => { },
|
|
62
|
+
warn: () => { },
|
|
63
|
+
error: () => { },
|
|
64
|
+
debug: () => { },
|
|
59
65
|
}),
|
|
60
66
|
};
|
|
61
67
|
}
|
|
@@ -68,6 +74,7 @@ function createMockStorageClient() {
|
|
|
68
74
|
setItem: (key: string, value: string) => storage.set(key, value),
|
|
69
75
|
removeItem: (key: string) => storage.delete(key),
|
|
70
76
|
}),
|
|
77
|
+
getStorage: () => storage,
|
|
71
78
|
};
|
|
72
79
|
}
|
|
73
80
|
|
|
@@ -76,6 +83,83 @@ describe("ForceUpdateClient", () => {
|
|
|
76
83
|
mockAppStateListeners.length = 0;
|
|
77
84
|
});
|
|
78
85
|
|
|
86
|
+
describe("initialization from current identity state", () => {
|
|
87
|
+
test("initializes version status when identity is already identified", () => {
|
|
88
|
+
const mockIdentity = createMockIdentityClient({
|
|
89
|
+
type: "identified",
|
|
90
|
+
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
91
|
+
version_info: { status: IdentifyVersionStatusEnum.UPDATE_REQUIRED, update: null },
|
|
92
|
+
});
|
|
93
|
+
const mockLogging = createMockLoggingClient();
|
|
94
|
+
const mockStorage = createMockStorageClient();
|
|
95
|
+
|
|
96
|
+
const client = new ForceUpdateClient(
|
|
97
|
+
mockLogging as never,
|
|
98
|
+
mockStorage as never,
|
|
99
|
+
mockIdentity as never
|
|
100
|
+
);
|
|
101
|
+
client.initialize();
|
|
102
|
+
|
|
103
|
+
// Should immediately have update_required status from initialization
|
|
104
|
+
expect(client.getVersionStatus().type).toBe("update_required");
|
|
105
|
+
|
|
106
|
+
client.shutdown();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("stays in initializing when identity is unidentified", () => {
|
|
110
|
+
const mockIdentity = createMockIdentityClient({ type: "unidentified" });
|
|
111
|
+
const mockLogging = createMockLoggingClient();
|
|
112
|
+
const mockStorage = createMockStorageClient();
|
|
113
|
+
|
|
114
|
+
const client = new ForceUpdateClient(
|
|
115
|
+
mockLogging as never,
|
|
116
|
+
mockStorage as never,
|
|
117
|
+
mockIdentity as never
|
|
118
|
+
);
|
|
119
|
+
client.initialize();
|
|
120
|
+
|
|
121
|
+
// Should stay in initializing since not yet identified
|
|
122
|
+
expect(client.getVersionStatus().type).toBe("initializing");
|
|
123
|
+
|
|
124
|
+
client.shutdown();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("emits status change during initialization when already identified", () => {
|
|
128
|
+
const mockIdentity = createMockIdentityClient({
|
|
129
|
+
type: "identified",
|
|
130
|
+
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
131
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
132
|
+
});
|
|
133
|
+
const mockLogging = createMockLoggingClient();
|
|
134
|
+
const mockStorage = createMockStorageClient();
|
|
135
|
+
|
|
136
|
+
const statusChanges: VersionStatus[] = [];
|
|
137
|
+
|
|
138
|
+
const client = new ForceUpdateClient(
|
|
139
|
+
mockLogging as never,
|
|
140
|
+
mockStorage as never,
|
|
141
|
+
mockIdentity as never
|
|
142
|
+
);
|
|
143
|
+
client.initialize();
|
|
144
|
+
|
|
145
|
+
// Subscribe after construction to verify initial status was set
|
|
146
|
+
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
147
|
+
|
|
148
|
+
// Trigger another identify to verify no duplicate
|
|
149
|
+
mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", {
|
|
150
|
+
type: "identified",
|
|
151
|
+
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
152
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Should only have one change from the second emit (initial was before subscription)
|
|
156
|
+
expect(statusChanges).toHaveLength(1);
|
|
157
|
+
expect(client.getVersionStatus().type).toBe("up_to_date");
|
|
158
|
+
|
|
159
|
+
client.shutdown();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
79
163
|
describe("updateFromVersionStatus single emission", () => {
|
|
80
164
|
test("emits VERSION_STATUS_CHANGED exactly once per identify cycle", async () => {
|
|
81
165
|
const mockIdentity = createMockIdentityClient();
|
|
@@ -88,6 +172,7 @@ describe("ForceUpdateClient", () => {
|
|
|
88
172
|
mockIdentity as never,
|
|
89
173
|
{ throttleMs: 0, checkCooldownMs: 0 }
|
|
90
174
|
);
|
|
175
|
+
client.initialize();
|
|
91
176
|
|
|
92
177
|
const statusChanges: VersionStatus[] = [];
|
|
93
178
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
@@ -124,6 +209,7 @@ describe("ForceUpdateClient", () => {
|
|
|
124
209
|
mockIdentity as never,
|
|
125
210
|
{ throttleMs: 0, checkCooldownMs: 0 }
|
|
126
211
|
);
|
|
212
|
+
client.initialize();
|
|
127
213
|
|
|
128
214
|
const statusChanges: VersionStatus[] = [];
|
|
129
215
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
@@ -157,6 +243,7 @@ describe("ForceUpdateClient", () => {
|
|
|
157
243
|
mockStorage as never,
|
|
158
244
|
mockIdentity as never
|
|
159
245
|
);
|
|
246
|
+
client.initialize();
|
|
160
247
|
|
|
161
248
|
const statusChanges: VersionStatus[] = [];
|
|
162
249
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
@@ -184,6 +271,7 @@ describe("ForceUpdateClient", () => {
|
|
|
184
271
|
mockStorage as never,
|
|
185
272
|
mockIdentity as never
|
|
186
273
|
);
|
|
274
|
+
client.initialize();
|
|
187
275
|
|
|
188
276
|
expect(mockAppStateListeners).toHaveLength(1);
|
|
189
277
|
|
|
@@ -194,6 +282,60 @@ describe("ForceUpdateClient", () => {
|
|
|
194
282
|
});
|
|
195
283
|
|
|
196
284
|
describe("throttle and cooldown", () => {
|
|
285
|
+
test("skips version check when identify returns null", async () => {
|
|
286
|
+
const mockIdentity = createMockIdentityClient();
|
|
287
|
+
const mockLogging = createMockLoggingClient();
|
|
288
|
+
const mockStorage = createMockStorageClient();
|
|
289
|
+
|
|
290
|
+
mockIdentity.setNextIdentifyResult(null);
|
|
291
|
+
|
|
292
|
+
const client = new ForceUpdateClient(
|
|
293
|
+
mockLogging as never,
|
|
294
|
+
mockStorage as never,
|
|
295
|
+
mockIdentity as never,
|
|
296
|
+
{ throttleMs: 0, checkCooldownMs: 0 }
|
|
297
|
+
);
|
|
298
|
+
client.initialize();
|
|
299
|
+
|
|
300
|
+
const statusChanges: VersionStatus[] = [];
|
|
301
|
+
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
302
|
+
|
|
303
|
+
const foregroundHandler = mockAppStateListeners[0];
|
|
304
|
+
await foregroundHandler("active");
|
|
305
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
306
|
+
|
|
307
|
+
// Should have called identify but no status changes (null result)
|
|
308
|
+
expect(mockIdentity.getIdentifyCallCount()).toBe(1);
|
|
309
|
+
expect(statusChanges).toHaveLength(0);
|
|
310
|
+
|
|
311
|
+
client.shutdown();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("checkCooldownMs -1 disables version checking entirely", async () => {
|
|
315
|
+
const mockIdentity = createMockIdentityClient();
|
|
316
|
+
const mockLogging = createMockLoggingClient();
|
|
317
|
+
const mockStorage = createMockStorageClient();
|
|
318
|
+
|
|
319
|
+
const client = new ForceUpdateClient(
|
|
320
|
+
mockLogging as never,
|
|
321
|
+
mockStorage as never,
|
|
322
|
+
mockIdentity as never,
|
|
323
|
+
{ throttleMs: 0, checkCooldownMs: -1 }
|
|
324
|
+
);
|
|
325
|
+
client.initialize();
|
|
326
|
+
|
|
327
|
+
const foregroundHandler = mockAppStateListeners[0];
|
|
328
|
+
|
|
329
|
+
// Trigger foreground
|
|
330
|
+
await foregroundHandler("active");
|
|
331
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
332
|
+
|
|
333
|
+
// Should not have called identify at all
|
|
334
|
+
expect(mockIdentity.getIdentifyCallCount()).toBe(0);
|
|
335
|
+
|
|
336
|
+
client.shutdown();
|
|
337
|
+
});
|
|
338
|
+
|
|
197
339
|
test("throttle prevents rapid foreground checks", async () => {
|
|
198
340
|
const mockIdentity = createMockIdentityClient();
|
|
199
341
|
const mockLogging = createMockLoggingClient();
|
|
@@ -205,6 +347,7 @@ describe("ForceUpdateClient", () => {
|
|
|
205
347
|
mockIdentity as never,
|
|
206
348
|
{ throttleMs: 1000, checkCooldownMs: 0 }
|
|
207
349
|
);
|
|
350
|
+
client.initialize();
|
|
208
351
|
|
|
209
352
|
const foregroundHandler = mockAppStateListeners[0];
|
|
210
353
|
|
|
@@ -238,6 +381,7 @@ describe("ForceUpdateClient", () => {
|
|
|
238
381
|
mockIdentity as never,
|
|
239
382
|
{ throttleMs: 0, checkCooldownMs: 5000 }
|
|
240
383
|
);
|
|
384
|
+
client.initialize();
|
|
241
385
|
|
|
242
386
|
const foregroundHandler = mockAppStateListeners[0];
|
|
243
387
|
|
|
@@ -261,13 +405,99 @@ describe("ForceUpdateClient", () => {
|
|
|
261
405
|
});
|
|
262
406
|
});
|
|
263
407
|
|
|
408
|
+
describe("stale storage state reset", () => {
|
|
409
|
+
test("resets stale 'checking' state from storage to initializing", () => {
|
|
410
|
+
const mockIdentity = createMockIdentityClient({ type: "unidentified" });
|
|
411
|
+
const mockLogging = createMockLoggingClient();
|
|
412
|
+
const mockStorage = createMockStorageClient();
|
|
413
|
+
|
|
414
|
+
// Pre-populate storage with stale "checking" state
|
|
415
|
+
mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "checking" }));
|
|
416
|
+
|
|
417
|
+
const client = new ForceUpdateClient(
|
|
418
|
+
mockLogging as never,
|
|
419
|
+
mockStorage as never,
|
|
420
|
+
mockIdentity as never
|
|
421
|
+
);
|
|
422
|
+
client.initialize();
|
|
423
|
+
|
|
424
|
+
// Should reset to initializing, not stay in checking
|
|
425
|
+
expect(client.getVersionStatus().type).toBe("initializing");
|
|
426
|
+
|
|
427
|
+
client.shutdown();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("resets stale 'initializing' state from storage to initializing", () => {
|
|
431
|
+
const mockIdentity = createMockIdentityClient({ type: "unidentified" });
|
|
432
|
+
const mockLogging = createMockLoggingClient();
|
|
433
|
+
const mockStorage = createMockStorageClient();
|
|
434
|
+
|
|
435
|
+
// Pre-populate storage with stale "initializing" state
|
|
436
|
+
mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "initializing" }));
|
|
437
|
+
|
|
438
|
+
const client = new ForceUpdateClient(
|
|
439
|
+
mockLogging as never,
|
|
440
|
+
mockStorage as never,
|
|
441
|
+
mockIdentity as never
|
|
442
|
+
);
|
|
443
|
+
client.initialize();
|
|
444
|
+
|
|
445
|
+
// Should reset to initializing (which it already is, but storage should be cleared)
|
|
446
|
+
expect(client.getVersionStatus().type).toBe("initializing");
|
|
447
|
+
|
|
448
|
+
client.shutdown();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("preserves valid 'up_to_date' state from storage", () => {
|
|
452
|
+
const mockIdentity = createMockIdentityClient({ type: "unidentified" });
|
|
453
|
+
const mockLogging = createMockLoggingClient();
|
|
454
|
+
const mockStorage = createMockStorageClient();
|
|
455
|
+
|
|
456
|
+
// Pre-populate storage with valid state
|
|
457
|
+
mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "up_to_date" }));
|
|
458
|
+
|
|
459
|
+
const client = new ForceUpdateClient(
|
|
460
|
+
mockLogging as never,
|
|
461
|
+
mockStorage as never,
|
|
462
|
+
mockIdentity as never
|
|
463
|
+
);
|
|
464
|
+
client.initialize();
|
|
465
|
+
|
|
466
|
+
// Should keep up_to_date from storage
|
|
467
|
+
expect(client.getVersionStatus().type).toBe("up_to_date");
|
|
468
|
+
|
|
469
|
+
client.shutdown();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("preserves valid 'update_required' state from storage", () => {
|
|
473
|
+
const mockIdentity = createMockIdentityClient({ type: "unidentified" });
|
|
474
|
+
const mockLogging = createMockLoggingClient();
|
|
475
|
+
const mockStorage = createMockStorageClient();
|
|
476
|
+
|
|
477
|
+
// Pre-populate storage with valid state
|
|
478
|
+
mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "update_required" }));
|
|
479
|
+
|
|
480
|
+
const client = new ForceUpdateClient(
|
|
481
|
+
mockLogging as never,
|
|
482
|
+
mockStorage as never,
|
|
483
|
+
mockIdentity as never
|
|
484
|
+
);
|
|
485
|
+
client.initialize();
|
|
486
|
+
|
|
487
|
+
// Should keep update_required from storage
|
|
488
|
+
expect(client.getVersionStatus().type).toBe("update_required");
|
|
489
|
+
|
|
490
|
+
client.shutdown();
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
264
494
|
describe("version status mapping", () => {
|
|
265
495
|
test.each([
|
|
266
496
|
[IdentifyVersionStatusEnum.UP_TO_DATE, "up_to_date"],
|
|
267
497
|
[IdentifyVersionStatusEnum.UPDATE_AVAILABLE, "update_available"],
|
|
268
498
|
[IdentifyVersionStatusEnum.UPDATE_REQUIRED, "update_required"],
|
|
269
|
-
[IdentifyVersionStatusEnum.UPDATE_RECOMMENDED, "
|
|
270
|
-
[IdentifyVersionStatusEnum.DISABLED, "
|
|
499
|
+
[IdentifyVersionStatusEnum.UPDATE_RECOMMENDED, "update_recommended"],
|
|
500
|
+
[IdentifyVersionStatusEnum.DISABLED, "disabled"],
|
|
271
501
|
])("maps %s to %s", (apiStatus, expectedType) => {
|
|
272
502
|
const mockIdentity = createMockIdentityClient();
|
|
273
503
|
const mockLogging = createMockLoggingClient();
|
|
@@ -278,6 +508,7 @@ describe("ForceUpdateClient", () => {
|
|
|
278
508
|
mockStorage as never,
|
|
279
509
|
mockIdentity as never
|
|
280
510
|
);
|
|
511
|
+
client.initialize();
|
|
281
512
|
|
|
282
513
|
const statusChanges: VersionStatus[] = [];
|
|
283
514
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|