All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
262 lines
7.1 KiB
TypeScript
262 lines
7.1 KiB
TypeScript
import {
|
|
Injector,
|
|
NgZone,
|
|
runInInjectionContext,
|
|
signal
|
|
} from '@angular/core';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { Store } from '@ngrx/store';
|
|
import { Subject, of } from 'rxjs';
|
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
|
import { ServerDirectoryFacade } from '../../server-directory';
|
|
import { UsersActions } from '../../../store/users/users.actions';
|
|
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
|
import type {
|
|
ChatEvent,
|
|
GameActivity,
|
|
GameMatchResponse,
|
|
MatchedGame,
|
|
User
|
|
} from '../../../shared-kernel';
|
|
import { GameActivityService } from './game-activity.service';
|
|
|
|
const alice = createUser('alice-id', 'alice-oder', 'Alice');
|
|
const bob = createUser('bob-id', 'bob-oder', 'Bob');
|
|
const carol = createUser('carol-id', 'carol-oder', 'Carol');
|
|
|
|
let contexts: ServiceContext[] = [];
|
|
|
|
describe('GameActivityService sync', () => {
|
|
beforeEach(() => {
|
|
contexts = [];
|
|
installLocalStorageMock();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const context of contexts) {
|
|
context.service.ngOnDestroy();
|
|
}
|
|
});
|
|
|
|
it('subscribes to incoming activity on browser clients without local process scanning', () => {
|
|
const context = createServiceContext({
|
|
currentUser: bob,
|
|
allUsers: [alice, bob],
|
|
electronApi: null
|
|
});
|
|
|
|
context.service.start();
|
|
context.incomingMessages.next({
|
|
type: 'game-activity',
|
|
fromPeerId: alice.oderId,
|
|
oderId: alice.oderId,
|
|
displayName: alice.displayName,
|
|
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
|
|
} as ChatEvent);
|
|
|
|
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateGameActivity({
|
|
userId: alice.id,
|
|
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
|
|
}));
|
|
});
|
|
|
|
it('broadcasts local activity changes to peers already online', async () => {
|
|
const matchedGame = createMatchedGame('game-2', 'Stardew Valley', 'StardewValley.exe');
|
|
const context = createServiceContext({
|
|
currentUser: alice,
|
|
allUsers: [alice, bob],
|
|
processNames: ['StardewValley.exe'],
|
|
gameMatchResponse: { games: [matchedGame] }
|
|
});
|
|
|
|
context.service.start();
|
|
|
|
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'game-activity',
|
|
oderId: alice.oderId,
|
|
displayName: alice.displayName,
|
|
gameActivity: expect.objectContaining({
|
|
id: matchedGame.id,
|
|
name: matchedGame.name,
|
|
iconUrl: matchedGame.iconUrl,
|
|
store: matchedGame.store
|
|
})
|
|
})));
|
|
});
|
|
|
|
it('sends current activity directly to peers that connect after the status was set', async () => {
|
|
const matchedGame = createMatchedGame('game-3', 'Hades', 'Hades.exe');
|
|
const context = createServiceContext({
|
|
currentUser: alice,
|
|
allUsers: [
|
|
alice,
|
|
bob,
|
|
carol
|
|
],
|
|
processNames: ['Hades.exe'],
|
|
gameMatchResponse: { games: [matchedGame] }
|
|
});
|
|
|
|
context.service.start();
|
|
|
|
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalled());
|
|
|
|
context.realtime.sendToPeer.mockClear();
|
|
context.peerConnected.next(carol.oderId);
|
|
|
|
expect(context.realtime.sendToPeer).toHaveBeenCalledWith(carol.oderId, expect.objectContaining({
|
|
type: 'game-activity',
|
|
oderId: alice.oderId,
|
|
displayName: alice.displayName,
|
|
gameActivity: expect.objectContaining({
|
|
id: matchedGame.id,
|
|
name: matchedGame.name,
|
|
iconUrl: matchedGame.iconUrl,
|
|
store: matchedGame.store
|
|
})
|
|
}));
|
|
});
|
|
});
|
|
|
|
interface ServiceContextOptions {
|
|
currentUser: User;
|
|
allUsers: User[];
|
|
electronApi?: { getRunningProcessNames: () => Promise<string[]> } | null;
|
|
processNames?: string[];
|
|
gameMatchResponse?: GameMatchResponse;
|
|
}
|
|
|
|
interface ServiceContext {
|
|
incomingMessages: Subject<ChatEvent>;
|
|
peerConnected: Subject<string>;
|
|
realtime: {
|
|
broadcastMessage: ReturnType<typeof vi.fn>;
|
|
sendToPeer: ReturnType<typeof vi.fn>;
|
|
};
|
|
service: GameActivityService;
|
|
store: {
|
|
dispatch: ReturnType<typeof vi.fn>;
|
|
};
|
|
}
|
|
|
|
function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|
const currentUser = signal<User | null>(options.currentUser);
|
|
const allUsers = signal<User[]>(options.allUsers);
|
|
const incomingMessages = new Subject<ChatEvent>();
|
|
const peerConnected = new Subject<string>();
|
|
const realtime = {
|
|
onMessageReceived: incomingMessages.asObservable(),
|
|
onPeerConnected: peerConnected.asObservable(),
|
|
broadcastMessage: vi.fn(),
|
|
sendToPeer: vi.fn()
|
|
};
|
|
const store = {
|
|
dispatch: vi.fn(),
|
|
selectSignal: vi.fn((selector: unknown) => {
|
|
if (selector === selectCurrentUser) {
|
|
return currentUser;
|
|
}
|
|
|
|
if (selector === selectAllUsers) {
|
|
return allUsers;
|
|
}
|
|
|
|
throw new Error('Unexpected selector requested by GameActivityService test.');
|
|
})
|
|
};
|
|
const electronApi = options.electronApi === undefined
|
|
? { getRunningProcessNames: vi.fn(async () => options.processNames ?? []) }
|
|
: options.electronApi;
|
|
const injector = Injector.create({
|
|
providers: [
|
|
{
|
|
provide: ElectronBridgeService,
|
|
useValue: { getApi: () => electronApi }
|
|
},
|
|
{
|
|
provide: HttpClient,
|
|
useValue: {
|
|
post: vi.fn(() => of(options.gameMatchResponse ?? { games: [] }))
|
|
}
|
|
},
|
|
{
|
|
provide: NgZone,
|
|
useValue: {
|
|
run: (fn: () => void) => fn(),
|
|
runOutsideAngular: (fn: () => void) => fn()
|
|
}
|
|
},
|
|
{
|
|
provide: RealtimeSessionFacade,
|
|
useValue: realtime
|
|
},
|
|
{
|
|
provide: ServerDirectoryFacade,
|
|
useValue: { getApiBaseUrl: () => 'http://localhost:3001/api' }
|
|
},
|
|
{
|
|
provide: Store,
|
|
useValue: store
|
|
}
|
|
]
|
|
});
|
|
const service = runInInjectionContext(injector, () => new GameActivityService());
|
|
const context = {
|
|
incomingMessages,
|
|
peerConnected,
|
|
realtime,
|
|
service,
|
|
store
|
|
};
|
|
|
|
contexts.push(context);
|
|
|
|
return context;
|
|
}
|
|
|
|
function createUser(id: string, oderId: string, displayName: string): User {
|
|
return {
|
|
id,
|
|
oderId,
|
|
username: displayName.toLowerCase(),
|
|
displayName,
|
|
status: 'online',
|
|
role: 'member',
|
|
joinedAt: 1
|
|
};
|
|
}
|
|
|
|
function createActivity(id: string, name: string): GameActivity {
|
|
return {
|
|
id,
|
|
name,
|
|
startedAt: 1_000
|
|
};
|
|
}
|
|
|
|
function createMatchedGame(id: string, name: string, processName: string): MatchedGame {
|
|
return {
|
|
id,
|
|
name,
|
|
iconUrl: `https://img.example.test/${id}.jpg`,
|
|
store: {
|
|
name: 'Steam',
|
|
slug: 'steam',
|
|
url: `https://store.steampowered.com/search/?term=${encodeURIComponent(name)}`
|
|
},
|
|
processName
|
|
};
|
|
}
|
|
|
|
function installLocalStorageMock(): void {
|
|
const values = new Map<string, string>();
|
|
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
setItem: (key: string, value: string) => values.set(key, value),
|
|
removeItem: (key: string) => values.delete(key),
|
|
clear: () => values.clear()
|
|
});
|
|
}
|