mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(b2-2): declare protocol_epoch in the schema and generate both constants protocol/schema.json gains protocol_epoch (1). genprotocol emits ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go constant to the schema so a stale regeneration fails the required check. * feat(b2-2): check the client's protocol epoch in the auth handshake The auth payload gains epoch (absent = 0). Outside [minClientEpoch, ProtocolEpoch] the server answers one auth_error with code protocol_epoch_unsupported, the client/server/min epochs, and a message naming which side to update, then closes 1008 like every other handshake failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep connecting; the epoch-1 fixtures are unchanged. * feat(b2-2): send the protocol epoch and offer the update on a refused connect ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended on purpose). On auth_error code protocol_epoch_unsupported with a newer server the dispatcher records the host in ui.store.updateRequiredHost and main.ts mounts the UpdateNotifier on the connect page, so a refused client gets the same Update Now banner it would have had on the main page. * feat(b2-2): withhold client releases newer than the server's protocol epoch The signed server-update manifest gains protocol_epoch (release.yml reads it from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the manifest and reads it; the client-update endpoint answers 204 when the release's epoch is newer than ws.ProtocolEpoch or the manifest does not verify. Releases without a manifest are epoch 0 and advertised as before. Docs: protocol.md Compatibility section, api.md, deployment.md, protocol README, CHANGELOG Unreleased. * docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it * ci: prove the protocol_epoch manifest read on every PR, not only at tag time * fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal Codex P1: on a first login or startup auto-login no overlay exists before auth_ok, so a refusal never re-rendered the connect page and the one-time read of updateRequiredHost missed it. The connect page now subscribes to it, and a later refusal replaces the banner. Codex P2: a refusal on reconnect went through the generic logout and deleted the stored credential although the token is still valid. clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it (the skip-auto-login flag is still set and, being sessionStorage, does not survive the relaunch the update triggers).
169 lines
4.6 KiB
TypeScript
169 lines
4.6 KiB
TypeScript
/**
|
|
* Common test utilities for OwnCord Tauri client tests.
|
|
* Provides store reset and async store waiting helpers.
|
|
*/
|
|
|
|
import type { Store } from "@lib/store";
|
|
import { authStore } from "@stores/auth.store";
|
|
import { channelsStore } from "@stores/channels.store";
|
|
import { membersStore } from "@stores/members.store";
|
|
import { messagesStore } from "@stores/messages.store";
|
|
import { voiceStore } from "@stores/voice.store";
|
|
import { uiStore } from "@stores/ui.store";
|
|
|
|
import type { AuthState } from "@stores/auth.store";
|
|
import type { ChannelsState } from "@stores/channels.store";
|
|
import type { MembersState } from "@stores/members.store";
|
|
import type { MessagesState } from "@stores/messages.store";
|
|
import type { VoiceState } from "@stores/voice.store";
|
|
import type { UiState } from "@stores/ui.store";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initial states (must match those in each store module)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AUTH_INITIAL: AuthState = {
|
|
token: null,
|
|
user: null,
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: false,
|
|
};
|
|
|
|
const CHANNELS_INITIAL: ChannelsState = {
|
|
channels: new Map(),
|
|
activeChannelId: null,
|
|
roles: [],
|
|
};
|
|
|
|
const MEMBERS_INITIAL: MembersState = {
|
|
members: new Map(),
|
|
typingUsers: new Map(),
|
|
};
|
|
|
|
const MESSAGES_INITIAL: MessagesState = {
|
|
messagesByChannel: new Map(),
|
|
pendingSends: new Map(),
|
|
loadedChannels: new Set(),
|
|
hasMore: new Map(),
|
|
historyLoadState: new Map(),
|
|
detachedChannels: new Set(),
|
|
};
|
|
|
|
const VOICE_INITIAL: VoiceState = {
|
|
currentChannelId: null,
|
|
voiceUsers: new Map(),
|
|
voiceConfigs: new Map(),
|
|
localMuted: false,
|
|
localDeafened: false,
|
|
localCamera: false,
|
|
localScreenshare: false,
|
|
joinedAt: null,
|
|
listenOnly: false,
|
|
voiceStatus: "idle",
|
|
};
|
|
|
|
const UI_INITIAL: UiState = {
|
|
sidebarCollapsed: false,
|
|
memberListVisible: true,
|
|
settingsOpen: false,
|
|
activeModal: null,
|
|
theme: "dark",
|
|
connectionStatus: "disconnected",
|
|
transientError: null,
|
|
persistentError: null,
|
|
updateRequiredHost: null,
|
|
collapsedCategories: new Set(),
|
|
sidebarMode: "channels",
|
|
activeDmUserId: null,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resetAllStores
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Reset every store to its initial state. Call this in `beforeEach` to
|
|
* ensure test isolation.
|
|
*/
|
|
export function resetAllStores(): void {
|
|
authStore.setState(() => ({ ...AUTH_INITIAL }));
|
|
channelsStore.setState(() => ({ ...CHANNELS_INITIAL, channels: new Map() }));
|
|
membersStore.setState(() => ({
|
|
...MEMBERS_INITIAL,
|
|
members: new Map(),
|
|
typingUsers: new Map(),
|
|
}));
|
|
messagesStore.setState(() => ({
|
|
...MESSAGES_INITIAL,
|
|
messagesByChannel: new Map(),
|
|
pendingSends: new Map(),
|
|
loadedChannels: new Set(),
|
|
hasMore: new Map(),
|
|
historyLoadState: new Map(),
|
|
detachedChannels: new Set(),
|
|
}));
|
|
voiceStore.setState(() => ({
|
|
...VOICE_INITIAL,
|
|
voiceUsers: new Map(),
|
|
voiceConfigs: new Map(),
|
|
}));
|
|
uiStore.setState(() => ({
|
|
...UI_INITIAL,
|
|
collapsedCategories: new Set(),
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// waitForStoreUpdate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns a promise that resolves when the store's state matches the given
|
|
* predicate. Useful for waiting on asynchronous store updates (e.g. after
|
|
* dispatching a WS message that triggers a store change).
|
|
*
|
|
* Times out after `timeoutMs` (default 2000ms) to prevent hanging tests.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* await waitForStoreUpdate(authStore, (s) => s.isAuthenticated);
|
|
* ```
|
|
*/
|
|
export function waitForStoreUpdate<T>(
|
|
store: Store<T>,
|
|
predicate: (state: T) => boolean,
|
|
timeoutMs = 2000,
|
|
): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
// Check immediately — predicate may already be true
|
|
const current = store.getState();
|
|
if (predicate(current)) {
|
|
resolve(current);
|
|
return;
|
|
}
|
|
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const unsub = store.subscribe((state) => {
|
|
if (predicate(state)) {
|
|
if (timer !== null) {
|
|
clearTimeout(timer);
|
|
}
|
|
unsub();
|
|
resolve(state);
|
|
}
|
|
});
|
|
|
|
timer = setTimeout(() => {
|
|
unsub();
|
|
reject(
|
|
new Error(
|
|
`waitForStoreUpdate timed out after ${timeoutMs}ms. ` +
|
|
`Last state: ${JSON.stringify(store.getState())}`,
|
|
),
|
|
);
|
|
}, timeoutMs);
|
|
});
|
|
}
|