@teardown/react-native 2.0.1 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +46 -18
- package/src/clients/api/api.client.ts +2 -2
- package/src/clients/device/{expo-adapter.ts → adapters/basic.adapter.ts} +1 -1
- package/src/clients/device/{device.adpater-interface.ts → adapters/device.adpater-interface.ts} +1 -16
- package/src/clients/device/adapters/expo.adapter.ts +90 -0
- package/src/clients/device/device.client.test.ts +1 -1
- 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 +238 -6
- 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 -20
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teardown/react-native",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -18,15 +18,20 @@
|
|
|
18
18
|
"import": "./src/exports/index.ts",
|
|
19
19
|
"default": "./src/exports/index.ts"
|
|
20
20
|
},
|
|
21
|
-
"./expo": {
|
|
22
|
-
"types": "./src/exports/expo.ts",
|
|
23
|
-
"import": "./src/exports/expo.ts",
|
|
24
|
-
"default": "./src/exports/expo.ts"
|
|
21
|
+
"./adapters/expo": {
|
|
22
|
+
"types": "./src/exports/adapters/expo.ts",
|
|
23
|
+
"import": "./src/exports/adapters/expo.ts",
|
|
24
|
+
"default": "./src/exports/adapters/expo.ts"
|
|
25
25
|
},
|
|
26
|
-
"./mmkv": {
|
|
27
|
-
"types": "./src/exports/mmkv.ts",
|
|
28
|
-
"import": "./src/exports/mmkv.ts",
|
|
29
|
-
"default": "./src/exports/mmkv.ts"
|
|
26
|
+
"./adapters/mmkv": {
|
|
27
|
+
"types": "./src/exports/adapters/mmkv.ts",
|
|
28
|
+
"import": "./src/exports/adapters/mmkv.ts",
|
|
29
|
+
"default": "./src/exports/adapters/mmkv.ts"
|
|
30
|
+
},
|
|
31
|
+
"./adapters/async-storage": {
|
|
32
|
+
"types": "./src/exports/adapters/async-storage.ts",
|
|
33
|
+
"import": "./src/exports/adapters/async-storage.ts",
|
|
34
|
+
"default": "./src/exports/adapters/async-storage.ts"
|
|
30
35
|
}
|
|
31
36
|
},
|
|
32
37
|
"scripts": {
|
|
@@ -40,11 +45,13 @@
|
|
|
40
45
|
"nuke": "cd ../../ && bun run nuke"
|
|
41
46
|
},
|
|
42
47
|
"dependencies": {
|
|
43
|
-
"@teardown/ingest-api": "0.1.
|
|
44
|
-
"@teardown/schemas": "0.1.
|
|
45
|
-
"@teardown/types": "0.1.
|
|
48
|
+
"@teardown/ingest-api": "0.1.41",
|
|
49
|
+
"@teardown/schemas": "0.1.41",
|
|
50
|
+
"@teardown/types": "0.1.41",
|
|
46
51
|
"eventemitter3": "^5.0.1",
|
|
47
|
-
"
|
|
52
|
+
"react-native-get-random-values": "^2.0.0",
|
|
53
|
+
"uuid": "^13.0.0",
|
|
54
|
+
"zod": "^4.1.13"
|
|
48
55
|
},
|
|
49
56
|
"devDependencies": {
|
|
50
57
|
"@elysiajs/eden": "^1.4.5",
|
|
@@ -55,13 +62,34 @@
|
|
|
55
62
|
"expo-updates": "^29.0.12"
|
|
56
63
|
},
|
|
57
64
|
"peerDependencies": {
|
|
65
|
+
"@react-native-async-storage/async-storage": "^1.21.0 || ^2.0.0",
|
|
58
66
|
"react": "*",
|
|
59
67
|
"react-native": "*",
|
|
60
68
|
"typescript": "*",
|
|
61
|
-
"expo-application": "
|
|
62
|
-
"expo-device": "
|
|
63
|
-
"expo-notifications": "
|
|
64
|
-
"
|
|
65
|
-
"
|
|
69
|
+
"expo-application": "*",
|
|
70
|
+
"expo-device": "*",
|
|
71
|
+
"expo-notifications": "*",
|
|
72
|
+
"expo-secure-store": "*",
|
|
73
|
+
"react-native-mmkv": "*"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"@react-native-async-storage/async-storage": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"react-native-mmkv": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"expo-application": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"expo-device": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"expo-notifications": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"expo-secure-store": {
|
|
92
|
+
"optional": true
|
|
93
|
+
}
|
|
66
94
|
}
|
|
67
95
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import * as Eden from "@elysiajs/eden";
|
|
1
|
+
import type * as Eden from "@elysiajs/eden";
|
|
2
2
|
import * as IngestApi from "@teardown/ingest-api";
|
|
3
3
|
import type { LoggingClient } from "../logging";
|
|
4
4
|
import type { StorageClient } from "../storage";
|
|
5
5
|
|
|
6
|
-
export { Eden, IngestApi };
|
|
6
|
+
export type { Eden, IngestApi };
|
|
7
7
|
|
|
8
8
|
const TEARDOWN_INGEST_URL = "https://ingest.teardown.dev";
|
|
9
9
|
const TEARDOWN_API_KEY_HEADER = "td-api-key";
|
|
@@ -8,8 +8,8 @@ import * as Application from "expo-application";
|
|
|
8
8
|
import * as Device from "expo-device";
|
|
9
9
|
import { Platform } from "react-native";
|
|
10
10
|
|
|
11
|
+
import { DevicePlatformEnum, NotificationPlatformEnum } from "../device.client";
|
|
11
12
|
import { DeviceInfoAdapter } from "./device.adpater-interface";
|
|
12
|
-
import { DevicePlatformEnum, NotificationPlatformEnum } from "./device.client";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Maps expo-device DeviceType to a string representation
|
package/src/clients/device/{device.adpater-interface.ts → adapters/device.adpater-interface.ts}
RENAMED
|
@@ -2,7 +2,6 @@ import type {
|
|
|
2
2
|
ApplicationInfo,
|
|
3
3
|
DeviceInfo,
|
|
4
4
|
HardwareInfo,
|
|
5
|
-
NotificationsInfo,
|
|
6
5
|
OSInfo
|
|
7
6
|
} from "@teardown/schemas";
|
|
8
7
|
|
|
@@ -16,32 +15,18 @@ import type {
|
|
|
16
15
|
*
|
|
17
16
|
*/
|
|
18
17
|
export abstract class DeviceInfoAdapter {
|
|
19
|
-
// -- Application Information --
|
|
20
18
|
/**
|
|
21
19
|
* The information about the application running.
|
|
22
20
|
*/
|
|
23
21
|
abstract get applicationInfo(): ApplicationInfo;
|
|
24
|
-
// -- Updates Information --
|
|
25
|
-
/**
|
|
26
|
-
* The information about the update running.
|
|
27
|
-
*/
|
|
28
|
-
// abstract get updateInfo(): UpdateInfo | null;
|
|
29
|
-
// -- Hardware Information --
|
|
30
22
|
/**
|
|
31
23
|
* The information about the hardware of the device.
|
|
32
24
|
*/
|
|
33
25
|
abstract get hardwareInfo(): HardwareInfo;
|
|
34
|
-
// -- OS Information --
|
|
35
26
|
/**
|
|
36
27
|
* The information about the operating system of the device.
|
|
37
28
|
*/
|
|
38
29
|
abstract get osInfo(): OSInfo;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The information about the notifications of the device.
|
|
42
|
-
*/
|
|
43
|
-
abstract get notificationsInfo(): NotificationsInfo;
|
|
44
|
-
|
|
45
30
|
/**
|
|
46
31
|
* The information about the device.
|
|
47
32
|
*/
|
|
@@ -50,7 +35,7 @@ export abstract class DeviceInfoAdapter {
|
|
|
50
35
|
application: this.applicationInfo,
|
|
51
36
|
hardware: this.hardwareInfo,
|
|
52
37
|
os: this.osInfo,
|
|
53
|
-
notifications:
|
|
38
|
+
notifications: null,
|
|
54
39
|
update: null,
|
|
55
40
|
});
|
|
56
41
|
}
|
|
@@ -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() {
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
|
2
2
|
import { EventEmitter } from "eventemitter3";
|
|
3
|
-
import { ForceUpdateClient, IdentifyVersionStatusEnum } from "./force-update.client";
|
|
3
|
+
import { ForceUpdateClient, IdentifyVersionStatusEnum, VERSION_STATUS_STORAGE_KEY } from "./force-update.client";
|
|
4
4
|
|
|
5
5
|
// Must mock react-native BEFORE any imports that use it
|
|
6
6
|
const mockAppStateListeners: ((state: string) => void)[] = [];
|
|
@@ -17,10 +17,11 @@ type IdentifyState = import("../identity").IdentifyState;
|
|
|
17
17
|
type IdentifyStateChangeEvents = import("../identity").IdentifyStateChangeEvents;
|
|
18
18
|
type VersionStatus = import("./force-update.client").VersionStatus;
|
|
19
19
|
|
|
20
|
-
function createMockIdentityClient() {
|
|
20
|
+
function createMockIdentityClient(initialState?: IdentifyState) {
|
|
21
21
|
const emitter = new EventEmitter<IdentifyStateChangeEvents>();
|
|
22
22
|
let identifyCallCount = 0;
|
|
23
|
-
let
|
|
23
|
+
let currentState: IdentifyState = initialState ?? { type: "unidentified" };
|
|
24
|
+
let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } | null = {
|
|
24
25
|
success: true,
|
|
25
26
|
data: { version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE } },
|
|
26
27
|
};
|
|
@@ -31,14 +32,20 @@ function createMockIdentityClient() {
|
|
|
31
32
|
emitter.addListener("IDENTIFY_STATE_CHANGED", listener);
|
|
32
33
|
return () => emitter.removeListener("IDENTIFY_STATE_CHANGED", listener);
|
|
33
34
|
},
|
|
35
|
+
getIdentifyState: () => currentState,
|
|
34
36
|
identify: async () => {
|
|
35
37
|
identifyCallCount++;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
if (nextIdentifyResult === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
currentState = { type: "identifying" };
|
|
42
|
+
emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
|
|
43
|
+
currentState = {
|
|
38
44
|
type: "identified",
|
|
39
45
|
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
40
46
|
version_info: { status: nextIdentifyResult.data?.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
41
|
-
}
|
|
47
|
+
};
|
|
48
|
+
emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
|
|
42
49
|
return nextIdentifyResult;
|
|
43
50
|
},
|
|
44
51
|
getIdentifyCallCount: () => identifyCallCount,
|
|
@@ -67,6 +74,7 @@ function createMockStorageClient() {
|
|
|
67
74
|
setItem: (key: string, value: string) => storage.set(key, value),
|
|
68
75
|
removeItem: (key: string) => storage.delete(key),
|
|
69
76
|
}),
|
|
77
|
+
getStorage: () => storage,
|
|
70
78
|
};
|
|
71
79
|
}
|
|
72
80
|
|
|
@@ -75,6 +83,83 @@ describe("ForceUpdateClient", () => {
|
|
|
75
83
|
mockAppStateListeners.length = 0;
|
|
76
84
|
});
|
|
77
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
|
+
|
|
78
163
|
describe("updateFromVersionStatus single emission", () => {
|
|
79
164
|
test("emits VERSION_STATUS_CHANGED exactly once per identify cycle", async () => {
|
|
80
165
|
const mockIdentity = createMockIdentityClient();
|
|
@@ -87,6 +172,7 @@ describe("ForceUpdateClient", () => {
|
|
|
87
172
|
mockIdentity as never,
|
|
88
173
|
{ throttleMs: 0, checkCooldownMs: 0 }
|
|
89
174
|
);
|
|
175
|
+
client.initialize();
|
|
90
176
|
|
|
91
177
|
const statusChanges: VersionStatus[] = [];
|
|
92
178
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
@@ -123,6 +209,7 @@ describe("ForceUpdateClient", () => {
|
|
|
123
209
|
mockIdentity as never,
|
|
124
210
|
{ throttleMs: 0, checkCooldownMs: 0 }
|
|
125
211
|
);
|
|
212
|
+
client.initialize();
|
|
126
213
|
|
|
127
214
|
const statusChanges: VersionStatus[] = [];
|
|
128
215
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
@@ -156,6 +243,7 @@ describe("ForceUpdateClient", () => {
|
|
|
156
243
|
mockStorage as never,
|
|
157
244
|
mockIdentity as never
|
|
158
245
|
);
|
|
246
|
+
client.initialize();
|
|
159
247
|
|
|
160
248
|
const statusChanges: VersionStatus[] = [];
|
|
161
249
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|
|
@@ -183,6 +271,7 @@ describe("ForceUpdateClient", () => {
|
|
|
183
271
|
mockStorage as never,
|
|
184
272
|
mockIdentity as never
|
|
185
273
|
);
|
|
274
|
+
client.initialize();
|
|
186
275
|
|
|
187
276
|
expect(mockAppStateListeners).toHaveLength(1);
|
|
188
277
|
|
|
@@ -193,6 +282,60 @@ describe("ForceUpdateClient", () => {
|
|
|
193
282
|
});
|
|
194
283
|
|
|
195
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
|
+
|
|
196
339
|
test("throttle prevents rapid foreground checks", async () => {
|
|
197
340
|
const mockIdentity = createMockIdentityClient();
|
|
198
341
|
const mockLogging = createMockLoggingClient();
|
|
@@ -204,6 +347,7 @@ describe("ForceUpdateClient", () => {
|
|
|
204
347
|
mockIdentity as never,
|
|
205
348
|
{ throttleMs: 1000, checkCooldownMs: 0 }
|
|
206
349
|
);
|
|
350
|
+
client.initialize();
|
|
207
351
|
|
|
208
352
|
const foregroundHandler = mockAppStateListeners[0];
|
|
209
353
|
|
|
@@ -237,6 +381,7 @@ describe("ForceUpdateClient", () => {
|
|
|
237
381
|
mockIdentity as never,
|
|
238
382
|
{ throttleMs: 0, checkCooldownMs: 5000 }
|
|
239
383
|
);
|
|
384
|
+
client.initialize();
|
|
240
385
|
|
|
241
386
|
const foregroundHandler = mockAppStateListeners[0];
|
|
242
387
|
|
|
@@ -260,6 +405,92 @@ describe("ForceUpdateClient", () => {
|
|
|
260
405
|
});
|
|
261
406
|
});
|
|
262
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
|
+
|
|
263
494
|
describe("version status mapping", () => {
|
|
264
495
|
test.each([
|
|
265
496
|
[IdentifyVersionStatusEnum.UP_TO_DATE, "up_to_date"],
|
|
@@ -277,6 +508,7 @@ describe("ForceUpdateClient", () => {
|
|
|
277
508
|
mockStorage as never,
|
|
278
509
|
mockIdentity as never
|
|
279
510
|
);
|
|
511
|
+
client.initialize();
|
|
280
512
|
|
|
281
513
|
const statusChanges: VersionStatus[] = [];
|
|
282
514
|
client.onVersionStatusChange((status) => statusChanges.push(status));
|