125 lines
7.1 KiB
JavaScript
125 lines
7.1 KiB
JavaScript
// 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();
|
|
}
|