Files
OwnCord/Client/tauri-client/tests/e2e/helpers.ts
T
J3vbandClaude Fable 5 8579cb5d91 fix: batch of 25 correctness fixes across server and client (#1370)
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)

* fix(db): 1 defect(s) (OC-0096)

* fix(admin): 1 defect(s) (OC-0097)

* fix(auth): 2 defect(s) (OC-0099, OC-0021)

* fix(voice): 1 defect(s) (OC-0018)

* fix(admin): 1 defect(s) (OC-0045)

* fix(api): 1 defect(s) (OC-0103)

* fix(client): 1 defect(s) (OC-0105)

* fix(client): 1 defect(s) (OC-0107)

* fix(api): 1 defect(s) (OC-0109)

* fix(api): 1 defect(s) (OC-0112)

* test(admin): compare restore bytes with bytes.Equal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0095, OC-0014)

OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.

OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.

* fix(profile): 2 defect(s) (OC-0100, OC-0102)

* fix(service): 1 defect(s) (OC-0022)

Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.

* fix(api): 1 defect(s) (OC-0048)

* chore(workflows): correct stale model labels in bughunt-fix phase details

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): 1 defect(s) (OC-0015)

* fix(voice): 1 defect(s) (OC-0002)

* test: fix two CI-only failures in the batch-4 test suite

The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.

The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:49:27 +02:00

1037 lines
35 KiB
TypeScript

/**
* Shared E2E test helpers — Tauri mock injection for browser-based testing.
*
* The app uses Tauri IPC through __TAURI_INTERNALS__.invoke:
* - HTTP: plugin:http|fetch → plugin:http|fetch_send → plugin:http|fetch_read_body
* - WS: ws_connect, ws_send, ws_disconnect + events ws-state, ws-message
* - Events: plugin:event|listen, plugin:event|unlisten
*/
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
// ---------------------------------------------------------------------------
// Mock data — basic
// ---------------------------------------------------------------------------
export const MOCK_TOKEN = "mock-session-token-abc123";
export const MOCK_LOGIN_RESPONSE = {
token: MOCK_TOKEN,
requires_2fa: false,
};
export const MOCK_LOGIN_2FA_RESPONSE = {
requires_2fa: true,
partial_token: "mock-partial-token",
};
export const MOCK_CHANNELS = [
{ id: 1, name: "general", type: "text", position: 0, category: null },
{ id: 2, name: "random", type: "text", position: 1, category: null },
];
export const MOCK_MESSAGES = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
],
has_more: false,
};
export const MOCK_ROLES = [
{ id: 1, name: "admin", color: "#ff0000", permissions: 0x40000000 },
{ id: 2, name: "moderator", color: "#00aaff", permissions: 0x1000000 },
{ id: 3, name: "member", color: null, permissions: 0x3 },
];
export const MOCK_READY_PAYLOAD = {
type: "ready",
payload: {
channels: MOCK_CHANNELS,
members: [
{ id: 1, username: "testuser", avatar: "", status: "online", role: "admin" },
{ id: 2, username: "otheruser", avatar: "", status: "online", role: "member" },
],
voice_states: [],
roles: MOCK_ROLES,
},
};
export const MOCK_AUTH_OK = {
type: "auth_ok",
payload: {
user: { id: 1, username: "testuser", avatar: "", role: "admin" },
server_name: "Test Server",
motd: "Welcome to the test server",
},
};
// ---------------------------------------------------------------------------
// Mock data — rich (for extended tests)
// ---------------------------------------------------------------------------
export const MOCK_CHANNELS_WITH_CATEGORIES = [
{ id: 1, name: "general", type: "text", position: 0, category: "Text Channels" },
{ id: 2, name: "random", type: "text", position: 1, category: "Text Channels" },
{ id: 3, name: "announcements", type: "text", position: 2, category: "Information" },
{ id: 10, name: "Voice Chat", type: "voice", position: 3, category: "Voice Channels" },
{ id: 11, name: "Music", type: "voice", position: 4, category: "Voice Channels" },
];
export const MOCK_MEMBERS_MULTI_ROLE = [
{ id: 1, username: "testuser", avatar: "", status: "online", role: "admin" },
{ id: 2, username: "moderator1", avatar: "", status: "online", role: "moderator" },
{ id: 3, username: "member1", avatar: "", status: "idle", role: "member" },
{ id: 4, username: "member2", avatar: "", status: "dnd", role: "member" },
{ id: 5, username: "offlineuser", avatar: "", status: "offline", role: "member" },
];
export const MOCK_MESSAGES_RICH = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 102,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "Hey @testuser, check this out!",
timestamp: "2026-03-15T10:01:00Z",
edited_at: null,
attachments: [],
reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: true }],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 103,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "```js\nconsole.log('code block');\n```",
timestamp: "2026-03-15T10:01:30Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 104,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Replying to your message",
timestamp: "2026-03-15T10:02:00Z",
edited_at: "2026-03-15T10:02:30Z",
attachments: [],
reactions: [],
reply_to: 102,
pinned: false,
deleted: false,
},
{
id: 105,
channel_id: 1,
user: { id: 3, username: "member1", avatar: "" },
content: "Check this image",
timestamp: "2026-03-15T10:03:00Z",
edited_at: null,
attachments: [
{
id: "1",
filename: "screenshot.png",
size: 102400,
mime: "image/png",
url: "/uploads/screenshot.png",
},
],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 106,
channel_id: 1,
user: { id: 3, username: "member1", avatar: "" },
content: "And this document",
timestamp: "2026-03-15T10:03:30Z",
edited_at: null,
attachments: [
{
id: "2",
filename: "report.pdf",
size: 512000,
mime: "application/pdf",
url: "/uploads/report.pdf",
},
],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
],
has_more: true,
};
// Remote users only — the ready payload must never claim the LOCAL user
// (id 1) is in a voice channel: the dispatcher treats "self in
// ready.voice_states while voiceStatus is idle" as stale state from a
// reload and immediately sends voice_leave + clears the local store
// (dispatcher.ts stale-voice cleanup), which would hide the widget again.
// Tests that need the widget visible must join via the real click path
// (see joinVoiceChannelByName).
export const MOCK_VOICE_STATE = [
{ user_id: 2, channel_id: 10, muted: true, deafened: false },
{ user_id: 3, channel_id: 10, muted: false, deafened: false },
];
export const MOCK_PINNED_MESSAGES = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
pinned: true,
edited_at: null,
deleted: false,
reply_to: null,
attachments: [],
reactions: [],
},
],
has_more: false,
};
export const MOCK_INVITES = [
{
id: 1,
code: "abc123",
url: "https://localhost:8443/invite/abc123",
use_count: 3,
max_uses: 10,
expires_at: "2026-04-15T00:00:00Z",
},
{
id: 2,
code: "xyz789",
url: "https://localhost:8443/invite/xyz789",
use_count: 0,
max_uses: 1,
expires_at: null,
},
];
// ---------------------------------------------------------------------------
// Ready payload builders
// ---------------------------------------------------------------------------
function buildReadyPayload(overrides?: {
channels?: unknown[];
members?: unknown[];
voice_states?: unknown[];
roles?: unknown[];
dm_channels?: unknown[];
}): unknown {
return {
type: "ready",
payload: {
channels: overrides?.channels ?? MOCK_CHANNELS,
members: overrides?.members ?? MOCK_READY_PAYLOAD.payload.members,
voice_states: overrides?.voice_states ?? [],
roles: overrides?.roles ?? MOCK_ROLES,
dm_channels: overrides?.dm_channels ?? [],
},
};
}
// ---------------------------------------------------------------------------
// WS handler registry helpers
// ---------------------------------------------------------------------------
/**
* Chat echo handlers for E2E testing.
* Returns wsHandler entries that simulate server-side chat_send, chat_edit,
* and chat_delete echo responses.
*/
export function chatEchoHandlers(): Array<{ type: string; handler: string }> {
return [
{
type: "chat_send",
handler: `
var p = parsed.payload;
var echo = {
type: "chat_message",
payload: {
id: Date.now(),
channel_id: p.channel_id,
user: { id: 1, username: "testuser", avatar: "" },
content: p.content,
timestamp: new Date().toISOString(),
edited_at: null,
attachments: p.attachments || [],
reactions: [],
reply_to: p.reply_to || null,
pinned: false,
deleted: false
}
};
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(echo));
}, 50);
`,
},
{
type: "chat_edit",
handler: `
var echo = {
type: "chat_edited",
payload: {
message_id: parsed.payload.message_id,
channel_id: parsed.payload.channel_id || 1,
content: parsed.payload.content,
edited_at: new Date().toISOString()
}
};
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(echo));
}, 50);
`,
},
{
type: "chat_delete",
handler: `
var echo = {
type: "chat_deleted",
payload: {
message_id: parsed.payload.message_id,
channel_id: parsed.payload.channel_id || 1
}
};
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(echo));
}, 50);
`,
},
];
}
/**
* Voice WS flow handlers for E2E testing.
* Simulates the server-side voice protocol defined in:
* docs/brain/06-Specs/PROTOCOL.md (voice_join, voice_leave, voice_token, voice_token_refresh)
* docs/protocol-schema.json (message type schemas)
*
* When PROTOCOL.md voice message types change, update these handlers to match.
*/
export function voiceWsHandlers(): Array<{ type: string; handler: string }> {
return [
{
type: "voice_join",
handler: `
var p = parsed.payload;
// Remember the joined channel so the voice_leave echo can carry the
// real channel id, like the server does (dispatcher's self-leave
// teardown is gated on a channel match, so a wrong id is ignored).
globalThis.__mockVoiceChannel = p.channel_id;
setTimeout(function() {
// Full VoiceStatePayload shape — the server always sends username
// (and the flag fields); the sidebar renders user.username directly,
// so an omitted username breaks the voice-user list render.
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_state",
payload: { user_id: 1, channel_id: p.channel_id, username: "testuser", muted: false, deafened: false, speaking: false, camera: false, screenshare: false }
}));
}, 50);
// Deliberately NO voice_token reply: a token makes the client start a
// real LiveKit session, which in the browser mock deterministically
// self-destructs (E2EE key exchange times out after ~15s, and
// Room.connect to the fake port fails after ~3 retries), tearing the
// widget down mid-test. These web tests validate the WS/UI layer only
// (see voice-lifecycle.spec.ts header); real LiveKit is covered by the
// native suite.
`,
},
{
type: "voice_leave",
handler: `
var ch = globalThis.__mockVoiceChannel || 0;
globalThis.__mockVoiceChannel = 0;
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_leave",
payload: { user_id: 1, channel_id: ch }
}));
}, 50);
`,
},
{
type: "voice_token_refresh",
handler: `
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_token",
payload: { token: "mock-livekit-token-refreshed", url: "ws://localhost:7880", channel_id: 0, direct_url: "" }
}));
}, 50);
`,
},
];
}
/**
* Voice join failure handler for E2E testing.
* Simulates a server error response when attempting to join a voice channel.
*/
export function voiceJoinFailureHandler(): { type: string; handler: string } {
return {
type: "voice_join",
handler: `
var p = parsed.payload;
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "error",
payload: { code: "VOICE_JOIN_FAILED", message: "Failed to join voice channel" }
}));
}, 50);
`,
};
}
// ---------------------------------------------------------------------------
// Tauri mock script builder
// ---------------------------------------------------------------------------
export function buildTauriMockScript(opts: {
httpRoutes: Array<{ pattern: string; status: number; body: unknown }>;
simulateWsFlow: boolean;
echoChatSend?: boolean;
wsHandlers?: Array<{ type: string; handler: string }>;
readyOverrides?: {
channels?: unknown[];
members?: unknown[];
voice_states?: unknown[];
dm_channels?: unknown[];
};
/** Pinned peer identity keys served by get_identity_pin, keyed by userId
* (string). Absent key = null = "never pinned". */
identityPins?: Record<string, string>;
/** Make get_identity_pin REJECT — models a transient keyring failure, the
* DC-08 fail-closed path. */
identityPinError?: boolean;
/** Seeds the `get_settings` payload — pass `{"owncord:profiles": {schemaVersion, profiles}}`
* to give the connect page saved server profiles (e.g. one with
* autoConnect) instead of the default empty store. */
storedSettings?: Record<string, unknown>;
/** Seeds `load_credential`. Default null = nothing stored. `delete_credential`
* stays a no-op, so a test can assert behaviour that must hold even when the
* credential is still readable. */
storedCredential?: { username: string; token: string } | null;
}): string {
const readyPayload = buildReadyPayload(opts.readyOverrides);
// Merge explicit wsHandlers with auto-generated chat echo handlers
const allWsHandlers: Array<{ type: string; handler: string }> = [
...(opts.wsHandlers ?? []),
...(opts.echoChatSend ? chatEchoHandlers() : []),
];
return `
// -----------------------------------------------------------------------
// Event system
// -----------------------------------------------------------------------
const __eventListeners = {};
let __callbackId = 0;
function __tauriEmitEvent(eventName, payload) {
const listeners = __eventListeners[eventName] || [];
for (const { handler } of listeners) {
try { handler({ payload, event: eventName, id: 0 }); }
catch (e) { console.error("[tauri-mock] event error", eventName, e); }
}
}
window.__tauriEmitEvent = __tauriEmitEvent;
// Exposed so tests can wait until a listener is registered before
// emitting — registration goes through an async invoke roundtrip, so
// emitting straight after page load races it.
window.__tauriEventListeners = __eventListeners;
// Chronological record of every IPC invoke ({ cmd, args }) — lets tests
// assert side effects with no DOM footprint (pin writes, restart calls).
window.__invokeLog = [];
// -----------------------------------------------------------------------
// HTTP mock state
// -----------------------------------------------------------------------
const HTTP_ROUTES = ${JSON.stringify(opts.httpRoutes)};
let __nextRid = 1;
const __pendingFetch = {}; // rid → { url, route }
const __pendingBody = {}; // responseRid → Uint8Array (body bytes)
let __bodyRead = {}; // responseRid → boolean (already read)
// Sort routes by pattern length (longest first) to match most specific route
HTTP_ROUTES.sort((a, b) => b.pattern.length - a.pattern.length);
function matchRoute(url) {
for (const route of HTTP_ROUTES) {
if (url.includes(route.pattern)) return route;
}
return null;
}
// -----------------------------------------------------------------------
// __TAURI_INTERNALS__
// -----------------------------------------------------------------------
window.__TAURI_INTERNALS__ = {
metadata: {
currentWindow: { label: "main" },
currentWebview: { label: "main" },
},
transformCallback: (callback, once) => {
const id = __callbackId++;
if (typeof callback === "function") {
window["__tcb_" + id] = callback;
}
return id;
},
invoke: async (cmd, args) => {
// Every IPC call is recorded so tests can assert side effects that
// have no DOM footprint (e.g. store_identity_pin, plugin:process|restart).
window.__invokeLog.push({ cmd, args });
// ---- Events ----
if (cmd === "plugin:event|listen") {
const eventName = args?.event;
const handlerId = args?.handler;
const cb = window["__tcb_" + handlerId];
if (eventName && cb) {
if (!__eventListeners[eventName]) __eventListeners[eventName] = [];
__eventListeners[eventName].push({ id: handlerId, handler: cb });
}
return handlerId || 0;
}
if (cmd === "plugin:event|unlisten") return;
// ---- HTTP: fetch (step 1 — register request, return rid) ----
if (cmd === "plugin:http|fetch") {
const url = args?.clientConfig?.url || args?.url || "";
const rid = __nextRid++;
const route = matchRoute(url);
__pendingFetch[rid] = { url, route };
return rid;
}
// ---- HTTP: fetch_send (step 2 — return status + headers) ----
if (cmd === "plugin:http|fetch_send") {
const rid = args?.rid;
const pending = __pendingFetch[rid];
delete __pendingFetch[rid];
const responseRid = __nextRid++;
if (pending?.route) {
const bodyStr = JSON.stringify(pending.route.body);
const encoder = new TextEncoder();
const bodyBytes = encoder.encode(bodyStr);
__pendingBody[responseRid] = bodyBytes;
__bodyRead[responseRid] = false;
return {
status: pending.route.status,
statusText: pending.route.status === 200 ? "OK" : "Error",
url: pending.url,
headers: [["content-type", "application/json"]],
rid: responseRid,
};
}
// No matching route — 404
const fallback = JSON.stringify({ error: "NOT_FOUND", message: "mocked 404" });
const encoder = new TextEncoder();
__pendingBody[responseRid] = encoder.encode(fallback);
__bodyRead[responseRid] = false;
return {
status: 404,
statusText: "Not Found",
url: pending?.url || "",
headers: [["content-type", "application/json"]],
rid: responseRid,
};
}
// ---- HTTP: fetch_read_body (step 3 — return body bytes) ----
if (cmd === "plugin:http|fetch_read_body") {
const rid = args?.rid;
const body = __pendingBody[rid];
if (body && !__bodyRead[rid]) {
__bodyRead[rid] = true;
const result = Array.from(body);
result.push(0); // 0 = not end yet
return result;
}
// End signal: [1]
delete __pendingBody[rid];
delete __bodyRead[rid];
return [1];
}
// ---- HTTP: cancel ----
if (cmd === "plugin:http|fetch_cancel" || cmd === "plugin:http|fetch_cancel_body") {
return;
}
// ---- WS commands ----
if (cmd === "ws_connect") {
${
opts.simulateWsFlow
? `
setTimeout(() => __tauriEmitEvent("ws-state", "open"), 100);
`
: ""
}
return;
}
if (cmd === "ws_send") {
${
opts.simulateWsFlow
? `
try {
var parsed = JSON.parse(args?.message || "{}");
if (parsed.type === "auth") {
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(MOCK_AUTH_OK)}));
}, 100);
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(readyPayload)}));
}, 200);
}
var WS_HANDLERS = ${JSON.stringify(allWsHandlers)};
for (var i = 0; i < WS_HANDLERS.length; i++) {
var h = WS_HANDLERS[i];
if (parsed.type === h.type) {
(new Function('parsed', '__tauriEmitEvent', h.handler))(parsed, __tauriEmitEvent);
}
}
} catch (e) {}
`
: ""
}
return;
}
if (cmd === "ws_disconnect") return;
// ---- HTTP TOFU proxy ----
// api.ts routes all REST calls through the Rust loopback proxy:
// baseUrl() awaits start_http_proxy and builds
// http://127.0.0.1:{port}/api/v1/... — if this returns null (the
// unhandled-command fallback), the URL gets a literal "null" port and
// Request construction throws before the plugin:http mock above is
// ever consulted, failing every login. Any numeric port works: the
// transport is still plugin:http|fetch and route matching is
// substring-based, so the fake origin never has to be listened on.
if (cmd === "start_http_proxy") return 45123;
if (cmd === "stop_http_proxy") return;
// ---- LiveKit proxy ----
if (cmd === "start_livekit_proxy") return { port: 7880 };
if (cmd === "stop_livekit_proxy") return;
// ---- Credentials ----
if (cmd === "save_credential" || cmd === "delete_credential") return null;
if (cmd === "load_credential") return ${JSON.stringify(opts.storedCredential ?? null)};
// ---- Settings ----
if (cmd === "get_settings") return ${JSON.stringify(opts.storedSettings ?? {})};
if (cmd === "save_settings") return;
// ---- Certs ----
if (cmd === "store_cert_fingerprint" || cmd === "get_cert_fingerprint") return null;
if (cmd === "accept_cert_fingerprint") return null;
// ---- E2EE identity (keyring blob + TOFU pins) ----
// null = "no stored key/pin". ensureIdentityKeyPublished on the ready
// event is fire-and-forget (void), so a null store is safe and just
// exercises the fresh-key path. Pins are configurable per test:
// identityPins seeds get_identity_pin per userId, identityPinError
// makes the read REJECT (the DC-08 "store unreadable" path).
if (cmd === "save_identity_key" || cmd === "load_identity_key" || cmd === "delete_identity_key") return null;
if (cmd === "get_identity_pin") {
${
opts.identityPinError === true
? `throw new Error("keyring unavailable (mock)");`
: `return ${JSON.stringify(opts.identityPins ?? {})}[String(args?.userId)] ?? null;`
}
}
if (cmd === "store_identity_pin") return null;
// ---- Window/webview plugin stubs ----
if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null;
console.log("[tauri-mock] unhandled invoke:", cmd);
return null;
},
convertFileSrc: (path) => path,
};
`;
}
// ---------------------------------------------------------------------------
// Public API — mock injection
// ---------------------------------------------------------------------------
export async function mockTauriConnect(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
],
simulateWsFlow: false,
}),
);
}
export async function mockTauriConnectWith2FA(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
],
simulateWsFlow: false,
}),
);
}
export async function mockTauriFullSession(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
],
simulateWsFlow: true,
}),
);
}
/**
* Full session where the connect page ALSO has a saved auto-connect profile
* for the same host, and `load_credential` still returns a usable credential.
* `delete_credential` remains a no-op, so a logout test built on this asserts
* the client refuses to auto-login on its own account rather than relying on
* the credential delete having already won a race.
*/
export async function mockTauriFullSessionWithAutoConnect(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
],
simulateWsFlow: true,
storedCredential: { username: "testuser", token: "stored-token" },
storedSettings: {
"owncord:profiles": {
schemaVersion: 1,
profiles: [
{
id: "p1",
name: "Local",
host: "localhost:8443",
username: "testuser",
autoConnect: true,
rememberPassword: true,
color: "#5865f2",
lastConnected: null,
},
],
},
},
}),
);
}
export async function mockTauriFullSessionWithMessages(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}),
);
}
export async function mockTauriFullSessionWithVoice(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
wsHandlers: voiceWsHandlers(),
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
voice_states: MOCK_VOICE_STATE,
},
}),
);
}
export async function mockTauriFullSessionWithVoiceFailure(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
wsHandlers: [voiceJoinFailureHandler()],
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
voice_states: MOCK_VOICE_STATE,
},
}),
);
}
export async function mockTauriFullSessionWithEcho(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
echoChatSend: true,
}),
);
}
export async function mockTauriFullSessionWithMessagesAndEcho(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
echoChatSend: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}),
);
}
export async function mockTauriFullSessionWithFailingMessages(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{
pattern: "/messages",
status: 500,
body: { error: "INTERNAL_ERROR", message: "Failed to load messages" },
},
],
simulateWsFlow: true,
}),
);
}
export async function mockTauriLoginError(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{
pattern: "/api/v1/auth/login",
status: 401,
body: { error: "INVALID_CREDENTIALS", message: "Invalid username or password" },
},
],
simulateWsFlow: false,
}),
);
}
// ---------------------------------------------------------------------------
// Public API — page actions
// ---------------------------------------------------------------------------
export async function submitLogin(page: Page): Promise<void> {
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
await page.locator("button.btn-primary[type='submit']").click();
}
/**
* Login and wait for the main app layout to appear.
*/
export async function navigateToMainPage(page: Page): Promise<void> {
await submitLogin(page);
const appLayout = page.locator("[data-testid='app-layout']");
await expect(appLayout).toBeVisible({ timeout: 15_000 });
}
/**
* Open the settings overlay via the user bar gear button.
*/
export async function openSettings(page: Page): Promise<void> {
const settingsBtn = page.locator("button[aria-label='Settings']");
await settingsBtn.click();
const overlay = page.locator("[data-testid='settings-overlay']");
await expect(overlay).toHaveClass(/open/, { timeout: 5_000 });
}
/**
* Switch to a settings tab by name.
*/
export async function switchSettingsTab(page: Page, tabName: string): Promise<void> {
const tab = page.locator(".settings-sidebar button.settings-nav-item", { hasText: tabName });
await tab.click();
await expect(tab).toHaveClass(/active/);
}
/**
* Emit a WebSocket event from the mock server to the client.
* Must be called after the page has loaded and WS listeners are registered.
*/
export async function emitWsEvent(page: Page, eventName: string, payload: unknown): Promise<void> {
await page.evaluate(
({ event, data }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__tauriEmitEvent(
event,
typeof data === "string" ? data : JSON.stringify(data),
);
},
{ event: eventName, data: payload },
);
}
/**
* Emit a WS message event (shorthand for ws-message).
*/
export async function emitWsMessage(page: Page, message: unknown): Promise<void> {
await emitWsEvent(page, "ws-message", JSON.stringify(message));
}
// ---------------------------------------------------------------------------
// Anti-flakiness utilities
// ---------------------------------------------------------------------------
/**
* Wait for the WS mock to finish its auth + ready handshake.
* The mock uses setTimeout(100ms) for ws-state open, then
* setTimeout(100/200ms) for auth_ok/ready — so the handshake
* completes within ~300ms. This helper polls for a reliable
* DOM signal instead of relying on hardcoded delays.
*/
export async function waitForWsReady(page: Page): Promise<void> {
// The channel sidebar populates after the ready payload is processed.
// Wait for the first channel item as proof the WS flow completed.
await expect(page.locator(".channel-item").first()).toBeVisible({ timeout: 10_000 });
}
/**
* Navigate to main page and wait for WS ready handshake to complete.
* Combines login + WS readiness in one call to reduce boilerplate
* and ensure tests start from a stable state.
*/
export async function navigateToMainPageReady(page: Page): Promise<void> {
await navigateToMainPage(page);
await waitForWsReady(page);
}
/**
* Join a voice channel through the real click path and wait for the voice
* widget to become visible. This is the only supported way for tests to get
* the local user into voice: pre-seeding the ready payload with user 1 no
* longer works (the dispatcher's stale-voice cleanup immediately leaves).
*/
export async function joinVoiceChannelByName(
page: Page,
channelName = "Voice Chat",
): Promise<void> {
await page.locator(".channel-item.voice", { hasText: channelName }).click();
await expect(page.locator("[data-testid='voice-widget']")).toHaveClass(/visible/, {
timeout: 10_000,
});
}
/**
* Emit a WS message and wait for a DOM change to confirm it was processed.
* Prevents flakiness from tests asserting before the message handler runs.
*
* @param page - Playwright page
* @param message - WS message payload to emit
* @param confirmLocator - Locator that should become visible/attached after processing
* @param timeout - Max wait time (default 5000ms)
*/
export async function emitWsMessageAndWait(
page: Page,
message: unknown,
confirmLocator: ReturnType<Page["locator"]>,
timeout = 5_000,
): Promise<void> {
await emitWsMessage(page, message);
await expect(confirmLocator).toBeVisible({ timeout });
}