Skip to content

Commit c482b6a

Browse files
authored
Fix offline version loading (Zagrios#1097)
* Fix offline version loading * Fix CI validation
1 parent 6af6bf5 commit c482b6a

14 files changed

Lines changed: 993 additions & 106 deletions

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"@types/use-double-click": "^1.0.4",
9999
"@types/webpack-bundle-analyzer": "^4.4.2",
100100
"@types/webpack-env": "^1.18.0",
101+
"@types/yauzl": "^3.4.0",
101102
"@typescript-eslint/eslint-plugin": "^6.20.0",
102103
"@typescript-eslint/parser": "^6.20.0",
103104
"autoprefixer": "^10.4.17",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { mkdtemp, rm, writeFile } from "fs/promises";
2+
import os from "os";
3+
import path from "path";
4+
import { BSLocalVersionService } from "main/services/bs-local-version.service";
5+
import { BSVersion } from "shared/bs-version.interface";
6+
7+
jest.mock("electron", () => ({
8+
app: { getPath: jest.fn(() => "") },
9+
}));
10+
jest.mock("electron-log", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }));
11+
jest.mock("main/services/bs-version-lib.service", () => ({
12+
BSVersionLibService: { getInstance: jest.fn(() => ({})) },
13+
}));
14+
jest.mock("main/services/installation-location.service", () => ({
15+
InstallationLocationService: { getInstance: jest.fn(() => ({})) },
16+
}));
17+
jest.mock("main/services/steam.service", () => ({
18+
SteamService: { getInstance: jest.fn(() => ({})) },
19+
}));
20+
jest.mock("main/services/oculus.service", () => ({
21+
OculusService: { getInstance: jest.fn(() => ({})) },
22+
}));
23+
jest.mock("main/services/configuration.service", () => ({
24+
ConfigurationService: { getInstance: jest.fn(() => ({})) },
25+
}));
26+
jest.mock("main/services/folder-linker.service", () => ({
27+
FolderLinkerService: { getInstance: jest.fn(() => ({})) },
28+
}));
29+
jest.mock("main/services/static-configuration.service", () => ({
30+
StaticConfigurationService: { getInstance: jest.fn(() => ({})) },
31+
}));
32+
33+
type BSLocalVersionServiceInternals = {
34+
versionLibService: {
35+
getCachedVersions: jest.Mock<Promise<BSVersion[]>, []>;
36+
};
37+
getVersionFromGlobalGameManagerFile(versionFilePath: string): Promise<BSVersion>;
38+
};
39+
40+
describe("BSLocalVersionService local version detection", () => {
41+
let tempDirectory: string;
42+
43+
beforeEach(async () => {
44+
tempDirectory = await mkdtemp(path.join(os.tmpdir(), "bs-manager-version-detection-"));
45+
});
46+
47+
afterEach(async () => {
48+
await rm(tempDirectory, { recursive: true, force: true });
49+
});
50+
51+
it("checks recent versions first without mutating the shared cached catalog", async () => {
52+
const versionFilePath = path.join(tempDirectory, "globalgamemanagers");
53+
await writeFile(versionFilePath, "metadata 1.2.10 metadata\n", "utf-8");
54+
55+
const cachedVersions = [
56+
{ BSVersion: "1.2" },
57+
{ BSVersion: "1.2.10" },
58+
] as BSVersion[];
59+
Object.freeze(cachedVersions);
60+
61+
const service = Object.create(BSLocalVersionService.prototype) as BSLocalVersionService;
62+
const internals = service as unknown as BSLocalVersionServiceInternals;
63+
internals.versionLibService = {
64+
getCachedVersions: jest.fn().mockResolvedValue(cachedVersions),
65+
};
66+
67+
await expect(internals.getVersionFromGlobalGameManagerFile(versionFilePath)).resolves.toEqual(cachedVersions[1]);
68+
expect(cachedVersions.map(version => version.BSVersion)).toEqual(["1.2", "1.2.10"]);
69+
});
70+
});
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { Observable, lastValueFrom, toArray } from "rxjs";
2+
import { BSVersionLibService } from "main/services/bs-version-lib.service";
3+
import { BSVersion } from "shared/bs-version.interface";
4+
5+
jest.mock("electron-log", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }));
6+
jest.mock("main/services/utils.service", () => ({
7+
UtilsService: { getInstance: jest.fn(() => ({ getAssestsJsonsPath: jest.fn(() => "C:/assets") })) },
8+
}));
9+
jest.mock("main/services/request.service", () => ({
10+
RequestService: { getInstance: jest.fn(() => ({ getJSON: jest.fn() })) },
11+
}));
12+
jest.mock("main/services/static-configuration.service", () => ({
13+
StaticConfigurationService: { getInstance: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) },
14+
}));
15+
16+
type Deferred<T> = {
17+
promise: Promise<T>;
18+
resolve: (value: T) => void;
19+
reject: (reason?: unknown) => void;
20+
};
21+
22+
type JSONResponse = {
23+
data: unknown;
24+
headers: Record<string, string>;
25+
};
26+
27+
type BSVersionLibInternals = {
28+
REMOTE_BS_VERSIONS_TIMEOUT: number;
29+
cachedVersions: BSVersion[] | null;
30+
loadPromise: Promise<BSVersion[]> | null;
31+
refreshPromise: Promise<BSVersion[] | null> | null;
32+
requestService: {
33+
getJSON: jest.Mock;
34+
};
35+
getLocalVersions: () => Promise<unknown>;
36+
updateLocalVersions: (versions: BSVersion[]) => Promise<void>;
37+
};
38+
39+
const LOCAL_VERSIONS: BSVersion[] = [
40+
{ BSVersion: "1.40.8", ReleaseDate: "2025-04-10" },
41+
];
42+
43+
const REMOTE_VERSIONS: BSVersion[] = [
44+
{ BSVersion: "1.40.8", ReleaseDate: "2025-04-10" },
45+
{ BSVersion: "1.41.0", ReleaseDate: "2025-05-08" },
46+
];
47+
48+
function deferred<T>(): Deferred<T> {
49+
let resolveDeferred: Deferred<T>["resolve"];
50+
let rejectDeferred: Deferred<T>["reject"];
51+
const promise = new Promise<T>((resolve, reject) => {
52+
resolveDeferred = resolve;
53+
rejectDeferred = reject;
54+
});
55+
56+
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
57+
}
58+
59+
function nextEventLoopTurn(): Promise<void> {
60+
return new Promise(resolve => {
61+
setImmediate(resolve);
62+
});
63+
}
64+
65+
function collect<T>(stream: Observable<T>): Promise<T[]> {
66+
return lastValueFrom(stream.pipe(toArray()));
67+
}
68+
69+
function createHarness(
70+
localResult: unknown | Promise<unknown> = LOCAL_VERSIONS,
71+
remoteRequest: jest.Mock = jest.fn().mockResolvedValue({ data: REMOTE_VERSIONS, headers: {} })
72+
) {
73+
(BSVersionLibService as any).instance = undefined;
74+
const service = BSVersionLibService.getInstance();
75+
const internals = service as unknown as BSVersionLibInternals;
76+
const getLocalVersions = jest.spyOn(internals, "getLocalVersions").mockReturnValue(Promise.resolve(localResult));
77+
const updateLocalVersions = jest.spyOn(internals, "updateLocalVersions").mockResolvedValue(undefined);
78+
internals.requestService = { getJSON: remoteRequest };
79+
80+
return { service, internals, getLocalVersions, updateLocalVersions, remoteRequest };
81+
}
82+
83+
describe("BSVersionLibService cache-first refresh", () => {
84+
beforeEach(() => {
85+
jest.clearAllMocks();
86+
(BSVersionLibService as any).instance = undefined;
87+
});
88+
89+
afterEach(() => {
90+
jest.restoreAllMocks();
91+
(BSVersionLibService as any).instance = undefined;
92+
});
93+
94+
it("loads the cached catalog without starting a remote request", async () => {
95+
const { service, getLocalVersions, updateLocalVersions, remoteRequest } = createHarness();
96+
97+
await expect(collect(service.getAvailableVersions$())).resolves.toEqual([LOCAL_VERSIONS]);
98+
await expect(service.getCachedVersions()).resolves.toBe(LOCAL_VERSIONS);
99+
expect(getLocalVersions).toHaveBeenCalledTimes(1);
100+
expect(remoteRequest).not.toHaveBeenCalled();
101+
expect(updateLocalVersions).not.toHaveBeenCalled();
102+
});
103+
104+
it("normalizes an invalid local catalog without starting a remote request", async () => {
105+
const { service, updateLocalVersions, remoteRequest } = createHarness([{ ReleaseDate: "2025-04-10" }]);
106+
107+
await expect(collect(service.getAvailableVersions$())).resolves.toEqual([[]]);
108+
await expect(service.getCachedVersions()).resolves.toEqual([]);
109+
expect(remoteRequest).not.toHaveBeenCalled();
110+
expect(updateLocalVersions).not.toHaveBeenCalled();
111+
});
112+
113+
it("emits the local catalog before the next event-loop turn when the remote request never settles", async () => {
114+
const remoteRequest = jest.fn(() => new Promise<JSONResponse>(() => {
115+
// Intentionally left pending to reproduce the stalled network request.
116+
}));
117+
const { service, getLocalVersions, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest);
118+
const timeout = jest.spyOn(global, "setTimeout").mockImplementation((() => 0) as unknown as typeof setTimeout);
119+
const emissions: BSVersion[][] = [];
120+
let completed = false;
121+
let streamError: unknown;
122+
123+
const subscription = service.getAvailableVersions$(true).subscribe({
124+
next: versions => emissions.push(versions),
125+
error: error => { streamError = error; },
126+
complete: () => { completed = true; },
127+
});
128+
129+
try {
130+
await nextEventLoopTurn();
131+
132+
expect(emissions).toEqual([LOCAL_VERSIONS]);
133+
expect(completed).toBe(false);
134+
expect(streamError).toBeUndefined();
135+
expect(getLocalVersions).toHaveBeenCalledTimes(1);
136+
expect(remoteRequest).toHaveBeenCalledTimes(1);
137+
expect(remoteRequest).toHaveBeenCalledWith(
138+
expect.any(String),
139+
{ signal: expect.any(AbortSignal), retryLimit: 0 }
140+
);
141+
expect(updateLocalVersions).not.toHaveBeenCalled();
142+
} finally {
143+
subscription.unsubscribe();
144+
timeout.mockRestore();
145+
}
146+
});
147+
148+
it("emits a successful remote refresh, updates memory, and persists it", async () => {
149+
const remote = deferred<JSONResponse>();
150+
const remoteRequest = jest.fn(() => remote.promise);
151+
const { service, getLocalVersions, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest);
152+
const emissionsPromise = collect(service.getAvailableVersions$(true));
153+
154+
await nextEventLoopTurn();
155+
remote.resolve({ data: REMOTE_VERSIONS, headers: {} });
156+
157+
await expect(emissionsPromise).resolves.toEqual([LOCAL_VERSIONS, REMOTE_VERSIONS]);
158+
await expect(service.getCachedVersions()).resolves.toBe(REMOTE_VERSIONS);
159+
expect(getLocalVersions).toHaveBeenCalledTimes(1);
160+
expect(remoteRequest).toHaveBeenCalledTimes(1);
161+
expect(updateLocalVersions).toHaveBeenCalledTimes(1);
162+
expect(updateLocalVersions).toHaveBeenCalledWith(REMOTE_VERSIONS);
163+
});
164+
165+
it("deduplicates concurrent streams, the local read, and the remote request", async () => {
166+
const local = deferred<BSVersion[]>();
167+
const remote = deferred<JSONResponse>();
168+
const remoteRequest = jest.fn(() => remote.promise);
169+
const { service, getLocalVersions, updateLocalVersions } = createHarness(local.promise, remoteRequest);
170+
171+
const firstStream = collect(service.getAvailableVersions$(true));
172+
const secondStream = collect(service.getAvailableVersions$(true));
173+
174+
expect(getLocalVersions).toHaveBeenCalledTimes(1);
175+
expect(remoteRequest).not.toHaveBeenCalled();
176+
177+
local.resolve(LOCAL_VERSIONS);
178+
await nextEventLoopTurn();
179+
180+
expect(remoteRequest).toHaveBeenCalledTimes(1);
181+
remote.resolve({ data: REMOTE_VERSIONS, headers: {} });
182+
183+
await expect(Promise.all([firstStream, secondStream])).resolves.toEqual([
184+
[LOCAL_VERSIONS, REMOTE_VERSIONS],
185+
[LOCAL_VERSIONS, REMOTE_VERSIONS],
186+
]);
187+
expect(getLocalVersions).toHaveBeenCalledTimes(1);
188+
expect(remoteRequest).toHaveBeenCalledTimes(1);
189+
expect(updateLocalVersions).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it("recovers from a local read failure with a valid remote catalog", async () => {
193+
const localError = Promise.reject<unknown>(new Error("corrupted local catalog"));
194+
const { service, getLocalVersions, updateLocalVersions, remoteRequest } = createHarness(localError);
195+
196+
await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([
197+
[],
198+
REMOTE_VERSIONS,
199+
]);
200+
await expect(service.getCachedVersions()).resolves.toBe(REMOTE_VERSIONS);
201+
expect(getLocalVersions).toHaveBeenCalledTimes(1);
202+
expect(remoteRequest).toHaveBeenCalledTimes(1);
203+
expect(updateLocalVersions).toHaveBeenCalledWith(REMOTE_VERSIONS);
204+
});
205+
206+
it.each([
207+
["a network error", () => Promise.reject(new Error("offline"))],
208+
["an empty remote catalog", () => Promise.resolve({ data: [], headers: {} })],
209+
["an invalid remote catalog", () => Promise.resolve({ data: [{}], headers: {} })],
210+
["an unchanged remote catalog", () => Promise.resolve({ data: LOCAL_VERSIONS.map(version => ({ ...version })), headers: {} })],
211+
])("keeps only the local emission without persistence after %s", async (_label, request) => {
212+
const remoteRequest = jest.fn(request);
213+
const { service, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest);
214+
215+
await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([LOCAL_VERSIONS]);
216+
await expect(service.getCachedVersions()).resolves.toBe(LOCAL_VERSIONS);
217+
expect(updateLocalVersions).not.toHaveBeenCalled();
218+
});
219+
220+
it("aborts a timed-out refresh, then completes with only the local catalog", async () => {
221+
const remoteRequest = jest.fn((_url: string, options: { signal: AbortSignal }) => (
222+
new Promise<JSONResponse>((_resolve, reject) => {
223+
options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
224+
})
225+
));
226+
const { service, internals, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest);
227+
internals.REMOTE_BS_VERSIONS_TIMEOUT = 0;
228+
229+
await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([LOCAL_VERSIONS]);
230+
expect(remoteRequest.mock.calls[0][1].signal.aborted).toBe(true);
231+
expect(updateLocalVersions).not.toHaveBeenCalled();
232+
});
233+
234+
it("keeps the remote emission and memory cache when persistence fails", async () => {
235+
const remoteRequest = jest.fn().mockResolvedValue({ data: REMOTE_VERSIONS, headers: {} });
236+
const { service, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest);
237+
updateLocalVersions.mockRejectedValue(new Error("read-only installation"));
238+
239+
await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([
240+
LOCAL_VERSIONS,
241+
REMOTE_VERSIONS,
242+
]);
243+
await expect(service.getCachedVersions()).resolves.toBe(REMOTE_VERSIONS);
244+
expect(updateLocalVersions).toHaveBeenCalledTimes(1);
245+
expect(updateLocalVersions).toHaveBeenCalledWith(REMOTE_VERSIONS);
246+
});
247+
});

0 commit comments

Comments
 (0)