Files
OwnCord/Client/tests/helpers/test-utils.ts
T
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* 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).
2026-08-29 07:23:06 +02:00

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);
});
}