// Launched only by TestIMRealtimeClientMySQL against its disposable loopback DB. // Executes the actual request/IM/state/page source with a Node uni transport adapter. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import ts from 'typescript'; import * as vue from 'vue'; import { parse } from 'vue/compiler-sfc'; const { origin, tokens } = JSON.parse(process.env.IM_REALTIME_FIXTURE || '{}'); assert.equal(new URL(origin).hostname, '127.0.0.1'); const hooks = {}, requests = [], tasks = [], frames = []; const storage = new Map([['accessToken', tokens[1]], ['userId', 2]]); globalThis.getCurrentPages = () => [{ route: 'pages/messages/index' }]; globalThis.uni = { getStorageSync: (key) => storage.get(key), setStorageSync: (key, value) => storage.set(key, value), removeStorageSync: (key) => storage.delete(key), stopPullDownRefresh() {}, showToast: () => { throw new Error('Unexpected visible API error'); }, reLaunch: () => { throw new Error('Unexpected login redirect'); }, request(options) { requests.push(new URL(options.url).pathname); fetch(options.url, { method: options.method || 'GET', headers: options.header, body: options.data ? JSON.stringify(options.data) : undefined, signal: AbortSignal.timeout(options.timeout || 15_000), }).then(async (response) => options.success({ statusCode: response.status, data: await response.json() })) .catch((error) => options.fail(error)); }, connectSocket(options) { const ws = new WebSocket(options.url, options.protocols); tasks.push(ws); return { onOpen: (fn) => ws.addEventListener('open', fn), onError: (fn) => ws.addEventListener('error', fn), onClose: (fn) => ws.addEventListener('close', fn), onMessage: (fn) => ws.addEventListener('message', fn), close: () => ws.close(), send: ({ data, fail }) => { try { ws.send(data); } catch (error) { fail(error); } }, }; }, }; const modules = new Map(); function sourceModule(file, page = false) { if (modules.has(file)) return modules.get(file); let source = readFileSync(new URL(`../src/${file}`, import.meta.url), 'utf8'); if (page) source = parse(source).descriptor.scriptSetup.content; // This adapter exercises H5 transport semantics on Node; no production env is loaded. source = source.replaceAll('import.meta.env', JSON.stringify({ VITE_API_ORIGIN: origin })) .replace('nativeApp = true;', 'nativeApp = false;'); const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; const exports = {}; const require = (name) => { if (name === 'vue') return vue; if (name === '@dcloudio/uni-app') return Object.fromEntries(['onShow', 'onHide', 'onUnload', 'onPullDownRefresh'].map((key) => [key, (fn) => { hooks[key] = fn; }])); if (name.endsWith('.vue')) return {}; if (name === './device') return { getDeviceId: () => 'fixture', getDeviceHeaders: () => ({}), getDeviceInformation: () => ({ platform: 'h5' }) }; return sourceModule(`utils/${name.split('/').at(-1)}.ts`); }; const result = new Function('exports', 'require', js + (page ? '\nreturn { items, loading };' : '\nreturn exports;'))(exports, require); modules.set(file, result); return result; } const waitFor = async (check, description, timeout = 2500) => { const deadline = Date.now() + timeout; while (!check()) { assert.ok(Date.now() < deadline, description); await new Promise((resolve) => setTimeout(resolve, 20)); } }; async function call(user, path, body) { const response = await fetch(`${origin}/api/v1${path}`, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens[user - 1]}` }, body: body ? JSON.stringify(body) : undefined, }); const payload = await response.json(); assert.equal(response.status, 200, 'fixture API request failed'); assert.equal(payload.code, 0); return payload.data; } let sequence = 0; const send = (user, conversation, text, clientMsgId = `fixture-${++sequence}`) => call(user, `/im/conversations/${conversation}/messages`, { clientMsgId, type: 1, content: { text } }); const page = sourceModule('pages/messages/index.vue', true); const im = sourceModule('utils/im.ts'); const state = sourceModule('utils/app-state.ts'); const off = im.connectIM((frame) => { frames.push(frame); }); try { hooks.onShow(); await waitFor(() => !page.loading.value && frames.some((frame) => frame.command === 'AUTH_ACK'), 'initial list/socket ready'); assert.deepEqual(page.items.value, []); const first = (await call(1, '/im/conversations/direct', { userId: 2 })).id; await send(1, first, '实时第一条', 'retry-same-id'); await waitFor(() => page.items.value[0]?.lastMessage === '实时第一条', 'incoming push updates list without onShow'); assert.equal(page.items.value[0].unread, 1); assert.equal(state.clientState.unreadMessages, 1); await send(1, first, '实时第一条', 'retry-same-id'); const second = (await call(3, '/im/conversations/direct', { userId: 2 })).id; await send(3, second, '另一个人发来消息'); await waitFor(() => page.items.value[0]?.id === second, 'new conversation inserted at the top'); assert.equal(state.clientState.unreadMessages, 2); await send(1, first, '回到列表首位'); await waitFor(() => page.items.value[0]?.id === first && page.items.value[0]?.unread === 2, 'existing conversation reordered'); assert.equal(state.clientState.unreadMessages, 3); await send(2, first, '我发送的回复'); await waitFor(() => page.items.value[0]?.lastMessage === '我发送的回复', 'outgoing echo updates preview'); assert.equal(state.clientState.unreadMessages, 3, 'own reply must not increase or clear unread'); im.suspendIM(); await send(1, first, '断线补偿'); await waitFor(() => page.items.value[0]?.lastMessage === '断线补偿', 'visible list recovers missed push while offline', 7500); assert.equal(state.clientState.unreadMessages, 4); im.resumeIM(); await waitFor(() => frames.filter((frame) => frame.command === 'AUTH_ACK').length >= 2, 'reconnect authenticated'); hooks.onHide(); im.suspendIM(); await send(1, first, '切回前台补齐'); hooks.onShow(); await waitFor(() => page.items.value[0]?.lastMessage === '切回前台补齐', 'foreground catches up'); assert.equal(state.clientState.unreadMessages, 5); await waitFor(() => frames.some((frame) => frame.command === 'PONG'), 'real backend heartbeat reply', 30_000); await send(1, first, '保活后仍然实时'); await waitFor(() => page.items.value[0]?.lastMessage === '保活后仍然实时', 'long-lived connection still receives messages'); assert.equal(state.clientState.unreadMessages, 6); assert.equal(requests.some((path) => /\/messages$/.test(path)), false, 'list synchronization never reads message history'); console.log('PASS: real HTTP + WebSocket + MySQL; actual client list/previews/order/unread, retry dedupe, reconnect, missed-push sync, foreground resume, PING/PONG; no read acknowledgements.'); } finally { hooks.onUnload(); off(); im.suspendIM(); for (const task of tasks) task.close(); }