This commit is contained in:
Your Name
2026-09-03 08:38:17 +08:00
parent 6cd4f1b1db
commit 842990b0e7
1853 changed files with 278406 additions and 361 deletions
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { parse, compileTemplate } from 'vue/compiler-sfc';
import ts from 'typescript';
const root = fileURLToPath(new URL('../src/', import.meta.url));
const source = readFileSync(join(root, 'utils/icon-mask.ts'), 'utf8');
const javascript = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 },
}).outputText;
const { ICON_PATHS, createIconMask } = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString('base64')}`);
const component = parse(readFileSync(join(root, 'components/AppIcon.vue'), 'utf8')).descriptor;
function nodes(node) {
return [node, ...(node.children ?? []).flatMap(nodes)];
}
function vueFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((item) => {
const file = join(dir, item.name);
return item.isDirectory() ? vueFiles(file) : file.endsWith('.vue') ? [file] : [];
});
}
function decodeMask(mask) {
assert.ok(mask.startsWith('url("data:image/svg+xml;charset=utf-8,'));
assert.ok(mask.endsWith('")'));
return decodeURIComponent(mask.slice(mask.indexOf(',') + 1, -2));
}
test('AppIcon renders a uni-app view, never unsupported raw svg/path elements', () => {
const elements = nodes(component.template.ast).filter((node) => node.type === 1);
assert.deepEqual(elements.map((node) => node.tag), ['view']);
const result = compileTemplate({ source: component.template.content, filename: 'AppIcon.vue', id: 'app-icon' });
assert.deepEqual(result.errors, []);
assert.doesNotMatch(result.code, /resolveComponent\(["'](?:svg|path)["']\)/);
assert.match(component.scriptSetup.content, /'-webkit-mask-image': mask/);
assert.match(component.scriptSetup.content, /'mask-image': mask/);
assert.match(component.styles[0].content, /background-color:\s*currentColor/);
assert.match(component.styles[0].content, /-webkit-mask-size:\s*100% 100%/);
});
test('every icon is self-contained with explicit SVG image styling', () => {
assert.ok(Object.keys(ICON_PATHS).length >= 35);
for (const [name, paths] of Object.entries(ICON_PATHS)) {
const svg = decodeMask(createIconMask(name));
assert.match(svg, /xmlns="http:\/\/www.w3.org\/2000\/svg"/);
assert.match(svg, /viewBox="0 0 24 24"/);
assert.match(svg, /fill="none" stroke="#000" stroke-width="1.8"/);
assert.equal((svg.match(/<path /g) || []).length, paths.length, name);
for (const path of paths) assert.ok(svg.includes(`d="${path}"`));
assert.doesNotMatch(svg, /(?:href|src)=|<script|currentColor/, name);
}
});
test('filled state, custom stroke widths, and unknown names stay usable', () => {
assert.match(decodeMask(createIconMask('heart', true, 2.4)), /fill="#000" stroke="#000" stroke-width="2.4"/);
assert.notEqual(createIconMask('heart'), createIconMask('heart', true));
assert.match(decodeMask(createIconMask('add', false, NaN)), /stroke-width="1.8"/);
assert.match(decodeMask(createIconMask('add', false, 999)), /stroke-width="8"/);
for (const name of ['unknown', '__proto__', 'constructor']) {
assert.equal(createIconMask(name), createIconMask('sparkles'));
}
});
test('all static AppIcon usages and bottom navigation names have real paths', () => {
let checked = 0;
for (const file of vueFiles(root)) {
const template = parse(readFileSync(file, 'utf8')).descriptor.template;
if (!template) continue;
for (const node of nodes(template.ast)) {
if (node.type !== 1 || node.tag !== 'AppIcon') continue;
const name = node.props.find((prop) => prop.type === 6 && prop.name === 'name')?.value?.content;
if (name) { assert.ok(Object.hasOwn(ICON_PATHS, name), `${file}: ${name}`); checked++; }
}
}
assert.ok(checked > 25);
for (const name of ['home', 'message', 'add', 'compass', 'user', 'search', 'pin', 'heart', 'back', 'more', 'smile', 'voice']) {
assert.ok(ICON_PATHS[name]?.length, name);
}
});
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
import * as vue from 'vue';
import { compileScript, parse } from 'vue/compiler-sfc';
function loadTS(source, imports = {}, globals = {}) {
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const exports = {};
new Function('exports', 'require', ...Object.keys(globals), js)(exports, name => {
assert.ok(imports[name], `unexpected module ${name}`);
return imports[name];
}, ...Object.values(globals));
return exports;
}
const helper = loadTS(readFileSync(new URL('../src/utils/avatar-image.ts', import.meta.url), 'utf8'));
test('versioned uploads use thumbnails, while external, local and signed URLs remain intact', () => {
for (const prefix of ['https://cdn.example.com/media/2026/08/', '/uploads/']) {
const src = `${prefix}42-1788189400000-av1.jpg`;
assert.equal(helper.avatarImageSource(src), `${prefix}42-1788189400000-av1-thumb.jpg`);
assert.equal(helper.avatarImageSource(src, 'display'), src);
}
for (const src of ['', undefined, 'https://cdn.example.com/42-1.png', 'https://oauth.example.com/photo.jpg',
'https://cdn.example.com/42-1-av1.jpg?signature=secret', 'https://cdn.example.com/42-1-av1.jpg#hash',
'https://cdn.example.com/42-1-av1-thumb.jpg', 'https://cdn.example.com/42-1-av1-original.png',
'/tmp/42-1-av1.jpg', 'file:///tmp/42-1-av1.jpg', 'blob:https://app.example.com/42-1-av1.jpg']) {
assert.equal(helper.avatarImageSource(src), src || '');
}
});
test('avatar component falls back once, then resets when reused for another user', async () => {
const source = readFileSync(new URL('../src/components/AvatarImage.vue', import.meta.url), 'utf8');
const { descriptor } = parse(source);
const script = compileScript(descriptor, { id: 'avatar-regression' });
const component = loadTS(script.content, { vue, '../utils/avatar-image': helper }).default;
const props = vue.reactive({ src: 'https://cdn.example.com/42-1-av1.jpg', variant: 'thumbnail', lazy: true });
const scope = vue.effectScope();
const state = scope.run(() => component.setup(props, { expose() {} }));
try {
assert.match(state.imageSource.value, /-thumb\.jpg$/);
state.onError();
assert.equal(state.imageSource.value, props.src);
state.onError();
assert.equal(state.imageSource.value, props.src);
props.src = 'https://cdn.example.com/43-2-av1.jpg';
await vue.nextTick();
assert.equal(state.imageSource.value, 'https://cdn.example.com/43-2-av1-thumb.jpg');
props.variant = 'display';
await vue.nextTick();
assert.equal(state.imageSource.value, props.src);
} finally { scope.stop(); }
});
function uploadClient(retry = false) {
const uploads = [];
const storage = { accessToken: 'old-token', refreshToken: 'refresh-token' };
const uni = {
getStorageSync: key => storage[key],
setStorageSync: (key, value) => { storage[key] = value; },
uploadFile(options) {
uploads.push(options);
queueMicrotask(() => options.success(retry && uploads.length === 1
? { statusCode: 401, data: JSON.stringify({ code: 401, message: 'expired' }) }
: { statusCode: 200, data: JSON.stringify({ code: 0, data: { url: 'https://cdn.example.com/42-1-av1.jpg' } }) }));
},
request(options) {
assert.match(options.url, /\/auth\/token\/refresh$/);
queueMicrotask(() => options.success({ statusCode: 200, data: { code: 0, data: { accessToken: 'new-token' } } }));
},
};
const source = readFileSync(new URL('../src/utils/request.ts', import.meta.url), 'utf8')
.replaceAll('import.meta.env.VITE_API_ORIGIN', '"https://api.example.com"');
const client = loadTS(source, { './device': { getDeviceHeaders: () => ({ 'X-Device-Id': 'test-device' }) },
'./network-errors': { isLoopbackServer: () => false } }, { uni });
return { client, uploads };
}
test('avatar upload purpose and refreshed authorization survive a token retry', async () => {
const { client, uploads } = uploadClient(true);
assert.equal(await client.api.uploadAvatar('/tmp/camera.jpg'), 'https://cdn.example.com/42-1-av1.jpg');
assert.equal(uploads.length, 2);
for (const upload of uploads) {
assert.deepEqual(upload.formData, { purpose: 'avatar' });
assert.equal(upload.filePath, '/tmp/camera.jpg');
assert.equal(upload.header['X-Device-Id'], 'test-device');
}
assert.equal(uploads[1].header.Authorization, 'Bearer new-token');
});
test('ordinary media uploads do not request avatar processing', async () => {
const { client, uploads } = uploadClient();
await client.api.uploadMedia('/tmp/evidence.png');
assert.equal(uploads.length, 1);
assert.equal(uploads[0].formData, undefined);
});
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { parse, compileScript } from 'vue/compiler-sfc';
import * as Vue from 'vue';
import ts from 'typescript';
const source = readFileSync(new URL('../src/components/BottomNav.vue', import.meta.url), 'utf8');
const descriptor = parse(source, { filename: 'BottomNav.vue' }).descriptor;
const css = descriptor.styles.map((style) => style.content).join('\n');
const compiled = compileScript(descriptor, { id: 'bottom-nav-test', inlineTemplate: true,
templateOptions: { compilerOptions: { isCustomElement: (tag) => ['view', 'text'].includes(tag) } },
});
const js = ts.transpileModule(compiled.content, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
} }).outputText;
function render(unread, active = 'home') {
const calls = [], exports = {};
globalThis.uni = {
reLaunch: ({ url }) => calls.push(['reLaunch', url]),
navigateTo: ({ url }) => calls.push(['navigateTo', url]),
};
new Function('require', 'exports', js)((name) => {
if (name === 'vue') return Vue;
if (name === './AppIcon.vue') return { default: { name: 'AppIcon' } };
if (name === '../utils/app-state') return { clientState: { unreadMessages: unread } };
throw new Error(name);
}, exports);
const result = exports.default.setup({ active }, { expose() {} })({}, []);
function flatten(node) {
if (!node || typeof node !== 'object') return [];
return [node, ...(Array.isArray(node.children) ? node.children.flatMap(flatten) : [])];
}
return { calls, nodes: flatten(result) };
}
test('bottom navigation explicitly overrides App-vue shrinking/clipping defaults', () => {
const button = css.match(/\.nav-item\s*\{([^}]+)\}/)?.[1];
for (const rule of [/\bwidth:\s*100%\s*;/, /\bmargin:\s*0\s*;/, /\boverflow:\s*visible\s*;/, /\bline-height:\s*1\s*;/]) {
assert.match(button, rule);
}
const badge = css.match(/\.badge\s*\{([^}]+)\}/)?.[1];
for (const rule of [/display:\s*flex/, /align-items:\s*center/, /justify-content:\s*center/, /white-space:\s*nowrap/, /pointer-events:\s*none/, /box-sizing:\s*border-box/]) {
assert.match(badge, rule);
}
assert.match(css, /safe-area-inset-bottom/);
});
test('badge appears only on messages, hides zero, and caps long counts at 99+', () => {
for (const [count, expected] of [[-1, null], [0, null], [1, '1'], [9, '9'], [99, '99'], [100, '99+'], [10000, '99+']]) {
const { nodes } = render(count);
const badges = nodes.filter((node) => node.props?.class === 'badge');
assert.equal(badges.length, expected === null ? 0 : 1);
if (expected !== null) assert.equal(badges[0].children.trim(), expected);
assert.equal(nodes.filter((node) => node.type === 'button').length, 5);
}
});
test('full-width tab buttons preserve navigation and the central publish action', () => {
const { calls, nodes } = render(100, 'messages');
const buttons = nodes.filter((node) => node.type === 'button');
assert.match(buttons.find((node) => node.props['aria-label'] === '消息').props.class, /active/);
for (const button of buttons) button.props.onTap();
assert.deepEqual(calls, [
['reLaunch', '/pages/home/index'], ['reLaunch', '/pages/messages/index'],
['navigateTo', '/pages/publish/index'], ['reLaunch', '/pages/feed/index'],
['reLaunch', '/pages/profile/index'],
]);
});
@@ -0,0 +1,124 @@
// 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();
}
@@ -0,0 +1,284 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
import { parse } from 'vue/compiler-sfc';
function clock() {
let now = 0, sequence = 0;
const jobs = new Map();
const add = (fn, ms, repeat = false) => {
const id = ++sequence;
jobs.set(id, { fn, at: now + ms, ms, repeat });
return id;
};
const timers = {
setTimeout: (fn, ms) => add(fn, ms), clearTimeout: (id) => jobs.delete(id),
setInterval: (fn, ms) => add(fn, ms, true), clearInterval: (id) => jobs.delete(id),
};
const flush = async () => { for (let i = 0; i < 15; i++) await Promise.resolve(); };
return {
timers, flush, pending: () => jobs.size,
async tick(ms) {
await flush();
const end = now + ms;
for (;;) {
const next = [...jobs].filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
const [id, job] = next;
now = job.at;
if (job.repeat) job.at += job.ms; else jobs.delete(id);
job.fn(); await flush();
}
now = end; await flush();
},
};
}
function evaluate(source, require, timer, suffix = '') {
const js = ts.transpileModule(source.replaceAll('import.meta.env', '{}'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
return new Function('exports', 'require', ...Object.keys(timer.timers), js + suffix)(
exports, require, ...Object.values(timer.timers),
) || exports;
}
function socketFixture() {
const timer = clock(), tasks = [], frames = [];
let token = 'session-a', network, refreshes = 0;
globalThis.uni = {
onNetworkStatusChange: (fn) => { network = fn; },
connectSocket: (options) => {
const callbacks = {};
const task = {
options, sent: [], closed: false,
onOpen: (fn) => { callbacks.open = fn; }, onMessage: (fn) => { callbacks.message = fn; },
onError: (fn) => { callbacks.error = fn; }, onClose: (fn) => { callbacks.close = fn; },
send(data) { this.sent.push(JSON.parse(data.data)); if (this.failSend) data.fail?.({}); },
close() { this.closed = true; callbacks.close?.({ code: 1000 }); },
open: () => callbacks.open(), error: () => callbacks.error({}),
message: (frame) => callbacks.message({ data: JSON.stringify(frame) }),
auth() { this.open(); this.message({ command: 'AUTH_ACK' }); },
};
tasks.push(task); return task;
},
};
const im = evaluate(readFileSync(new URL('../src/utils/im.ts', import.meta.url), 'utf8'), () => ({
API_ORIGIN: 'https://test.invalid', getToken: () => token,
ensureAccessToken: async () => { refreshes++; token = 'refreshed-session'; return true; },
}), timer);
const off = im.connectIM((frame) => frames.push(frame));
return { ...im, timer, tasks, frames, off, network: (state) => network(state),
token: (value) => { token = value; }, refreshes: () => refreshes };
}
test('half-open sockets without a PONG are closed and reconnected', async () => {
const f = socketFixture();
try {
f.tasks[0].auth();
await f.timer.tick(40_000);
assert.equal(f.tasks[0].closed, true, 'a stale SocketTask must not block reconnection forever');
assert.ok(f.tasks.length >= 2);
} finally { f.off(); }
});
test('handshake watchdog, send failures and network changes recover without duplicate sockets', async () => {
const f = socketFixture();
try {
await f.timer.tick(15_000);
assert.ok(f.tasks.length >= 2, 'a connectSocket call with no callbacks must time out');
const current = f.tasks.at(-1); current.auth(); current.failSend = true;
await f.timer.tick(27_000);
assert.equal(current.closed, true);
f.tasks.at(-1).auth();
f.network({ isConnected: false });
assert.equal(f.tasks.at(-1).closed, true);
const count = f.tasks.length;
await f.timer.tick(35_000); assert.equal(f.tasks.length, count);
f.network({ isConnected: true }); assert.equal(f.tasks.length, count + 1);
f.network({ isConnected: true }); assert.equal(f.tasks.length, count + 1);
} finally { f.off(); }
});
test('foreground resume and account changes replace stale sockets and ignore late frames', async () => {
const f = socketFixture();
try {
const old = f.tasks[0]; old.auth();
f.suspendIM(); await f.timer.tick(60_000);
assert.equal(f.tasks.length, 1);
f.resumeIM(); assert.equal(f.tasks.length, 2); f.tasks[1].auth();
f.token('session-b'); f.resumeIM();
assert.deepEqual(f.tasks[2].options.protocols, ['xingyu.jwt.session-b']);
const before = f.frames.length;
old.message({ command: 'MESSAGE_PUSH', data: { secret: 'old account' } });
assert.equal(f.frames.length, before);
f.token(''); f.resumeIM(); await f.timer.tick(60_000);
assert.equal(f.tasks.length, 3);
} finally { f.off(); }
assert.equal(f.timer.pending(), 0);
});
test('only AUTH_ACK establishes IM readiness; listener exceptions cannot swallow other deliveries', async () => {
const f = socketFixture();
const offBad = f.connectIM(() => { throw new Error('broken page'); });
try {
f.tasks[0].open();
assert.equal(f.frames.some((frame) => frame.data?.status === 'connected'), false);
f.tasks[0].message({ command: 'AUTH_ACK' });
const seen = [];
const offPage = f.connectIM((frame) => seen.push(frame.command));
f.tasks[0].message({ command: 'MESSAGE_PUSH', data: { id: 1 } });
assert.ok(seen.includes('MESSAGE_PUSH'));
offPage();
await f.timer.tick(25_000); f.tasks[0].message({ command: 'PONG' });
await f.timer.tick(11_000); assert.equal(f.tasks.length, 1);
} finally { offBad(); f.off(); }
});
test('failed authentication handshake refreshes credentials even without close code 1008', async () => {
const f = socketFixture();
try {
f.tasks[0].error(); await f.timer.tick(2_000);
assert.equal(f.refreshes(), 1);
assert.deepEqual(f.tasks.at(-1).options.protocols, ['xingyu.jwt.refreshed-session']);
} finally { f.off(); }
});
function messagesFixture() {
const timer = clock(), hooks = {}, listeners = new Set(), replies = [], calls = [], counts = [];
let result = { items: [] };
globalThis.uni = { stopPullDownRefresh() {}, navigateTo() {} };
const source = parse(readFileSync(new URL('../src/pages/messages/index.vue', import.meta.url), 'utf8')).descriptor.scriptSetup.content;
const page = evaluate(source, (name) => {
if (name === 'vue') return { ref: (value) => ({ value }) };
if (name === '@dcloudio/uni-app') return Object.fromEntries(['onLoad', 'onShow', 'onHide', 'onUnload', 'onPullDownRefresh'].map((key) => [key, (fn) => { hooks[key] = fn; }]));
if (name.endsWith('.vue')) return {};
if (name.endsWith('/request')) return {
getToken: () => 'session-a',
api: { conversations: async (options) => { calls.push(options); return replies.length ? replies.shift() : result; } },
};
if (name.endsWith('/im')) return { resumeIM() {}, connectIM: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
if (name.endsWith('/app-state')) return { syncConversationUnread: (items) => { counts.push(items.reduce((sum, item) => sum + item.unread, 0)); } };
if (name.endsWith('/message-display')) return { formatConversationTime: (value) => value };
throw new Error(name);
}, timer, '\nreturn {items,loading};');
return { ...page, timer, hooks, calls, replies, counts,
setResult: (value) => { result = { items: value }; },
emit: (frame) => listeners.forEach((fn) => fn(frame)),
show: async () => { hooks.onLoad?.(); hooks.onShow(); await timer.flush(); },
close: () => hooks.onUnload(),
};
}
test('visible message list reconciles missed pushes and unread badge without navigation', async () => {
const f = messagesFixture();
try {
await f.show();
const latest = [{ id: 2, lastMessage: 'new message', unread: 1 }, { id: 1, lastMessage: 'old', unread: 0 }];
f.setResult(latest);
await f.timer.tick(5_500);
assert.deepEqual(f.items.value, latest, 'remaining on the page must not require a push or a second onShow');
assert.equal(f.counts.at(-1), 1);
assert.equal(f.calls.at(-1).silent, true);
f.hooks.onHide(); const count = f.calls.length;
await f.timer.tick(60_000); assert.equal(f.calls.length, count);
f.hooks.onShow(); await f.timer.flush(); assert.ok(f.calls.length > count);
} finally { f.close(); }
assert.equal(f.timer.pending(), 0);
});
test('push bursts refresh previews/order/counts without skeleton flashes or starvation', async () => {
const f = messagesFixture();
try {
await f.show();
f.setResult([{ id: 1, lastMessage: 'incoming', unread: 1 }]);
for (let i = 0; i < 15; i++) {
f.emit({ command: 'MESSAGE_PUSH' });
await f.timer.tick(100);
assert.equal(f.loading.value, false);
}
assert.equal(f.items.value[0]?.lastMessage, 'incoming');
assert.ok(f.calls.length > 1 && f.calls.length < 15);
f.setResult([{ id: 1, lastMessage: 'incoming', unread: 0 }]);
f.emit({ command: 'READ_ACK' }); await f.timer.tick(200);
assert.equal(f.items.value[0].unread, 0);
} finally { f.close(); }
});
test('push during a pending fetch is replayed once; hidden-page responses cannot overwrite resumed state', async () => {
const f = messagesFixture();
try {
await f.show();
let resolve;
f.replies.push(new Promise((done) => { resolve = done; }));
f.emit({ command: 'MESSAGE_PUSH' }); await f.timer.tick(200);
const before = f.calls.length;
for (let i = 0; i < 10; i++) f.emit({ command: 'MESSAGE_PUSH' });
await f.timer.tick(200); assert.equal(f.calls.length, before);
f.setResult([{ id: 2, lastMessage: 'latest', unread: 2 }]);
resolve({ items: [] }); await f.timer.flush(); await f.timer.tick(200);
assert.equal(f.calls.length, before + 1); assert.equal(f.items.value[0].id, 2);
f.replies.push(new Promise((done) => { resolve = done; }));
f.emit({ command: 'MESSAGE_RECALLED' }); await f.timer.tick(200);
f.hooks.onHide(); f.setResult([{ id: 3, lastMessage: 'after resume', unread: 1 }]);
f.hooks.onShow(); await f.timer.flush();
resolve({ items: [{ id: 99, unread: 99 }] }); await f.timer.flush();
assert.equal(f.items.value[0].id, 3);
} finally { f.close(); }
});
test('connection-status flapping cannot postpone reconciliation; failed refresh preserves the list and retries', async () => {
const f = messagesFixture();
try {
f.setResult([{ id: 1, lastMessage: 'retained', unread: 1 }]);
await f.show();
f.setResult([{ id: 2, lastMessage: 'caught up', unread: 2 }]);
for (let i = 0; i < 6; i++) {
f.emit({ command: 'IM_STATUS', data: { status: 'reconnecting' } });
await f.timer.tick(1000);
}
assert.equal(f.items.value[0].id, 2);
// The deferred rejection happens after the request has installed its catch handler.
let reject;
f.replies.push(new Promise((_, fail) => { reject = fail; }));
f.emit({ command: 'MESSAGE_PUSH' }); await f.timer.tick(200);
reject(new Error('offline')); await f.timer.flush();
assert.equal(f.items.value[0].id, 2);
f.setResult([{ id: 3, lastMessage: 'retry success', unread: 3 }]);
await f.timer.tick(5_100);
assert.equal(f.items.value[0].id, 3);
f.emit({ command: 'IM_STATUS', data: { status: 'connected' } }); await f.timer.tick(200);
f.setResult([{ id: 4, lastMessage: 'lost push with healthy socket', unread: 4 }]);
await f.timer.tick(15_100);
assert.equal(f.items.value[0].id, 4);
} finally { f.close(); }
});
test('silent list requests do not toast on each retry and keep normal error feedback', async () => {
const timer = clock(), notices = [], requests = [];
let reply = 'network';
globalThis.uni = {
getStorageSync: () => '', showToast: (value) => notices.push(value),
request(options) {
requests.push(options);
if (reply === 'network') options.fail({ errMsg: 'offline' });
else options.success({ statusCode: 503, data: { message: 'retry later' } });
},
};
const source = readFileSync(new URL('../src/utils/request.ts', import.meta.url), 'utf8');
const request = evaluate(source, (name) => {
if (name === './device') return { getDeviceHeaders: () => ({}) };
if (name === './network-errors') return { isLoopbackServer: () => false, networkFailureMessage: () => 'offline' };
throw new Error(name);
}, timer);
await assert.rejects(request.api.conversations({ silent: true }));
assert.equal(notices.length, 0);
assert.equal('silent' in requests[0], false, 'internal option must not leak to uni.request');
reply = 'server';
await assert.rejects(request.api.conversations({ silent: true }));
assert.equal(notices.length, 0);
await assert.rejects(request.api.conversations());
assert.equal(notices.length, 1);
});
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
import { parse } from 'vue/compiler-sfc';
function loadModule(file, require = () => { throw new Error('Unexpected import'); }) {
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const result = {};
new Function('exports', 'require', js)(result, require);
return result;
}
const display = loadModule('../src/utils/message-display.ts');
test('conversation timestamps support RFC3339 and legacy nullable times, never NaN', () => {
const now = new Date(2026, 7, 31, 12, 23);
const stamp = new Date(2026, 7, 31, 8, 5).toISOString();
assert.equal(display.formatConversationTime(stamp, now), '08:05');
assert.equal(display.formatConversationTime({ Valid: true, Time: stamp }, now), '08:05');
assert.equal(display.formatConversationTime(new Date(2026, 7, 30, 12).toISOString(), now), '8-30');
for (const invalid of [null, undefined, '', {}, 123, 'not-a-date', '0001-01-01T00:00:00Z', { Valid: false, Time: stamp }, { Valid: true, Time: 'invalid' }]) {
assert.equal(display.formatConversationTime(invalid, now), '');
}
});
test('unread totals use the authoritative count, accept zero, reject invalid counts', () => {
assert.equal(display.unreadTotal([{ unread: 0, unreadCount: 3 }, { unread: '2' }, { unread: -2 }, { unread: 'bad' }, { unreadCount: 1 }]), 3);
});
test('HTTP send response and WebSocket echo create one outgoing message in either order', () => {
const optimistic = { id: 'local-1', clientMsgId: 'client-1', senderId: 1, status: 'sending' };
const saved = { ...optimistic, id: 7, seq: 1, status: 'sent' };
let items = display.upsertMessage([optimistic], saved);
items = display.upsertMessage(items, saved);
assert.deepEqual(items, [saved]);
assert.deepEqual(display.upsertMessage([optimistic, saved], saved), [saved]);
assert.equal(display.upsertMessage(items, { ...saved, id: 8, senderId: 2 }).length, 2);
});
function stateFixture() {
let listener, token = 'test-session', serverUnread = 0, requests = 0;
const replies = [];
const state = loadModule('../src/utils/app-state.ts', (name) => {
if (name === 'vue') return { reactive: (value) => value };
if (name === './message-display') return display;
if (name === './im') return { connectIM: (fn) => { listener = fn; return () => { listener = undefined; }; } };
if (name === './request') return {
getToken: () => token,
api: {
conversations: () => { requests++; return replies.length ? replies.shift() : Promise.resolve({ items: [{ unread: serverUnread }] }); },
notifications: async () => ({ unread: 0 }), appConfig: async () => ({}),
},
};
throw new Error(name);
});
return { ...state, emit: (frame) => listener(frame), setUnread: (value) => { serverUnread = value; }, replies, requests: () => requests, logout: () => { token = ''; state.stopClientServices(); } };
}
globalThis.uni = {};
test('own echoes and duplicate recipient pushes do not accumulate unread badges; reads/recalls resync', async () => {
const f = stateFixture();
try {
await f.startClientServices();
const own = { command: 'MESSAGE_PUSH', data: { id: 1, senderId: 1, conversationId: 1 } };
f.emit(own); f.emit(own);
await f.refreshUnreadCounts();
assert.equal(f.clientState.unreadMessages, 0);
f.setUnread(1);
const incoming = { ...own, data: { ...own.data, id: 2, senderId: 2 } };
f.emit(incoming); f.emit(incoming);
await f.refreshUnreadCounts();
assert.equal(f.clientState.unreadMessages, 1);
for (const command of ['READ_ACK', 'MESSAGE_RECALLED', 'MESSAGE_RESTORED']) {
const before = f.requests();
f.setUnread(command === 'MESSAGE_RESTORED' ? 1 : 0);
f.emit({ command });
await new Promise((resolve) => setTimeout(resolve, 220));
assert.equal(f.requests(), before + 1);
assert.equal(f.clientState.unreadMessages, command === 'MESSAGE_RESTORED' ? 1 : 0);
}
} finally { f.stopClientServices(); }
});
test('stale requests and logged-out sessions cannot resurrect unread badges', async () => {
const f = stateFixture();
await f.startClientServices();
let resolveOld;
f.replies.push(new Promise((resolve) => { resolveOld = resolve; }));
const old = f.refreshUnreadCounts();
f.setUnread(0);
await f.refreshUnreadCounts();
resolveOld({ items: [{ unread: 9 }] });
await old;
assert.equal(f.clientState.unreadMessages, 0);
let resolveLogout;
f.replies.push(new Promise((resolve) => { resolveLogout = resolve; }));
const pending = f.refreshUnreadCounts();
f.logout();
resolveLogout({ items: [{ unread: 8 }] });
await pending;
assert.equal(f.clientState.unreadMessages, 0);
});
test('actual chat page acknowledges only visible incoming messages and merges outgoing echoes', async () => {
const hooks = {}, reads = [], state = { activeConversationId: 0 };
let listener, history = [], historyRequests = 0, totalsRefreshes = 0;
globalThis.uni = { getStorageSync: () => 1, getSystemInfoSync: () => ({ uniPlatform: 'web' }) };
const source = parse(readFileSync(new URL('../src/pages/chat/index.vue', import.meta.url), 'utf8')).descriptor.scriptSetup.content;
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const api = {
messages: async () => { historyRequests++; return { items: [...history], hasMore: false }; },
profile: async () => ({ nickname: '对方', avatar: '' }), conversations: async () => ({ items: [{ id: 10, unread: 0 }] }),
conversationSettings: async (id, value) => { reads.push([id, value.readSeq]); },
sendMessage: async (conversationId, type, content, clientMsgId) => {
const saved = { id: 7, seq: 2, senderId: 1, conversationId, clientMsgId, type, content };
await listener({ command: 'MESSAGE_PUSH', data: saved });
return saved;
},
};
const require = (name) => {
if (name === 'vue') return { ref: (value) => ({ value }), computed: (fn) => ({ get value() { return fn(); } }), nextTick: async () => {} };
if (name === '@dcloudio/uni-app') return Object.fromEntries(['onLoad', 'onShow', 'onHide', 'onUnload'].map((key) => [key, (fn) => { hooks[key] = fn; }]));
if (name.endsWith('.vue')) return {};
if (name.endsWith('/im')) return { connectIM: (fn) => { listener = fn; return () => {}; } };
if (name.endsWith('/request')) return { api, messageId: () => 'outgoing-id' };
if (name.endsWith('/message-display')) return display;
if (name.endsWith('/app-state')) return { clientState: state, setActiveConversation: (id) => { state.activeConversationId = id || 0; }, refreshUnreadCounts: async () => { totalsRefreshes++; } };
throw new Error(name);
};
const page = new Function('exports', 'require', js + '\nreturn {items,sendPayload};')({}, require);
const tick = () => new Promise((resolve) => setImmediate(resolve));
hooks.onLoad({ id: '10', userId: '2' }); hooks.onShow(); await tick();
assert.equal(state.activeConversationId, 10);
assert.equal(historyRequests, 1);
await page.sendPayload(1, { text: 'hello' });
assert.equal(page.items.value.length, 1);
assert.equal(page.items.value[0].id, 7);
assert.equal(reads.length, 0, 'an outgoing echo must not acknowledge incoming messages');
const incoming = { id: 8, seq: 3, senderId: 2, conversationId: 10, clientMsgId: 'peer-id' };
await listener({ command: 'MESSAGE_PUSH', data: incoming });
await listener({ command: 'MESSAGE_PUSH', data: incoming });
assert.equal(page.items.value.length, 2);
assert.deepEqual(reads, [[10, 3], [10, 3]]);
assert.ok(totalsRefreshes >= 3);
hooks.onHide();
const count = reads.length;
await listener({ command: 'MESSAGE_PUSH', data: { ...incoming, id: 9, seq: 4 } });
assert.equal(reads.length, count);
assert.equal(state.activeConversationId, 0);
history = [incoming]; hooks.onShow(); await tick();
assert.equal(historyRequests, 2);
hooks.onUnload();
});
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
import ts from "typescript";
const root = fileURLToPath(new URL("../src/", import.meta.url));
const nativeControls = new Set([
"switch", "slider", "picker", "picker-view", "checkbox", "checkbox-group",
"radio", "radio-group",
]);
function vueFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? vueFiles(path) : path.endsWith(".vue") ? [path] : [];
});
}
function compile(source, filename) {
return compileTemplate({
source, filename, id: "native-component-regression",
compilerOptions: {
isNativeTag: (tag) => nativeControls.has(tag) || ["view", "text", "input", "textarea"].includes(tag),
},
});
}
function elements(node) {
return [node, ...(node.children ?? []).flatMap(elements)].filter((item) => item.type === 1);
}
test("the check detects unsupported v-model on native switches", () => {
const result = compile('<switch v-model="enabled" />', "invalid-switch.vue");
assert.ok(result.errors.some((error) => String(error.message).includes("v-model can only be used")));
});
test("all mobile templates compile without unsupported native v-model", () => {
const files = vueFiles(root);
assert.ok(files.length > 0);
for (const filename of files) {
const { descriptor, errors } = parse(readFileSync(filename, "utf8"), { filename });
assert.deepEqual(errors, [], relative(root, filename));
if (!descriptor.template) continue;
const result = compile(descriptor.template.content, filename);
assert.deepEqual(result.errors, [], relative(root, filename));
}
});
for (const [page, model] of [["settings", "privacy"], ["notification-settings", "form"]]) {
test(`${page} switch change updates both checked and unchecked form values`, () => {
const filename = join(root, "pages", page, "index.vue");
const { descriptor } = parse(readFileSync(filename, "utf8"), { filename });
const switches = elements(descriptor.template.ast).filter((node) => node.tag === "switch");
assert.equal(switches.length, 1);
const props = switches[0].props;
assert.ok(!props.some((prop) => prop.name === "model"));
const checked = props.find((prop) => prop.name === "bind" && prop.arg?.content === "checked");
const change = props.find((prop) => prop.name === "on" && prop.arg?.content === "change");
assert.ok(checked?.exp?.content);
assert.ok(change?.exp?.content);
const state = { enabled: false, otherSetting: true };
// Exercise the actual event handler and bindings without mounting a native
// runtime or calling the API; only the component's own handler is evaluated.
const script = compileScript(descriptor, { id: `test-${page}` });
const handler = script.scriptSetupAst.find((node) => node.type === "FunctionDeclaration" && node.id.name === "setSwitch");
assert.ok(handler);
const handlerSource = descriptor.scriptSetup.content.slice(handler.start, handler.end);
const handlerJS = ts.transpileModule(handlerSource, {
compilerOptions: { target: ts.ScriptTarget.ES2020 },
}).outputText;
const setSwitch = new Function(model, `${handlerJS}; return setSwitch;`)({ value: state });
const update = new Function("setSwitch", "item", "$event", change.exp.content);
const readChecked = new Function(model, "item", `return ${checked.exp.content}`);
for (const value of [true, false, true]) {
update(setSwitch, ["enabled"], { detail: { value } });
assert.equal(state.enabled, value);
assert.equal(readChecked(state, ["enabled"]), value);
assert.equal(state.otherSetting, true);
}
});
}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { nativeConfigFailures } from "./validate-native-config.mjs";
const env = { VITE_API_ORIGIN: "https://im.bchongw.com", VITE_WS_ORIGIN: "wss://im.bchongw.com" };
const manifest = {
appid: "__UNI__A031845",
"app-plus": {
modules: Object.fromEntries(["Camera", "Geolocation", "OAuth", "Payment", "Push", "Record"].map((name) => [name, {}])),
distribute: { ios: { privacyDescription: { NSCameraUsageDescription: "用于拍摄头像" } } },
},
};
test("supports the assigned seven-character AppID and existing eight-character IDs", () => {
for (const appid of ["__UNI__A031845", "__UNI__AB123456"]) {
assert.deepEqual(nativeConfigFailures({ ...manifest, appid }, env), []);
}
});
test("still rejects empty or placeholder AppIDs and insecure or malformed origins", () => {
for (const appid of ["", "__UNI__XXXXXXXX", "__UNI__XINGYUIM"]) {
assert.ok(nativeConfigFailures({ ...manifest, appid }, env).some((error) => error.includes("AppID")));
}
for (const VITE_API_ORIGIN of ["", "https://", "http://im.bchongw.com", "https://127.0.0.1:8888", "https://localhost", "https://[::1]", "https://im.bchongw.com/api/v1"]) {
assert.ok(nativeConfigFailures(manifest, { ...env, VITE_API_ORIGIN }).some((error) => error.includes("HTTPS")));
}
assert.ok(nativeConfigFailures(manifest, { ...env, VITE_WS_ORIGIN: "ws://im.bchongw.com" }).some((error) => error.includes("WSS")));
});
test("loads production env and manifest independently of the caller working directory", () => {
const childEnv = { ...process.env };
delete childEnv.VITE_API_ORIGIN;
delete childEnv.VITE_WS_ORIGIN;
const result = spawnSync(process.execPath, [fileURLToPath(new URL("./validate-native-config.mjs", import.meta.url))], {
cwd: fileURLToPath(new URL("../../", import.meta.url)),
env: childEnv,
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /基础配置校验通过/);
});
test("native login configuration never embeds the WeChat server secret", () => {
const withOAuth = (oauth) => ({ ...manifest, 'app-plus': {
...manifest['app-plus'], distribute: { ...manifest['app-plus'].distribute, sdkConfigs: { oauth } },
} });
assert.ok(nativeConfigFailures(withOAuth({ weixin: { appid: 'wx1234', appsecret: 'server-secret' } }), env).some((error) => error.includes('AppSecret')));
assert.ok(nativeConfigFailures(withOAuth({ qq: { appid: '' } }), env).some((error) => error.includes('QQ')));
assert.deepEqual(nativeConfigFailures(withOAuth({ weixin: { appid: 'wx1234' }, qq: { appid: '123456' } }), env), []);
assert.deepEqual(nativeConfigFailures(withOAuth({ google: {} }), env), []);
});
test('real-device development validates the development environment, not production', () => {
const childEnv = { ...process.env };
delete childEnv.VITE_API_ORIGIN;
delete childEnv.VITE_WS_ORIGIN;
const script = fileURLToPath(new URL('./validate-native-config.mjs', import.meta.url));
const result = spawnSync(process.execPath, [script, '--mode', 'development'], {
cwd: fileURLToPath(new URL('../../', import.meta.url)), env: childEnv, encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /mode=development API=https:\/\/im\.bchongw\.com WS=wss:\/\/im\.bchongw\.com/);
const overridden = spawnSync(process.execPath, [script, '--mode', 'development'], {
env: { ...childEnv, VITE_API_ORIGIN: 'http://127.0.0.1:8888' }, encoding: 'utf8',
});
assert.notEqual(overridden.status, 0, 'invalid shell override must not pass using production configuration');
});
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = readFileSync(new URL('../src/utils/network-errors.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const helpers = {};
new Function('exports', js)(helpers);
test('App loopback configuration is explained without misclassifying remote hosts', () => {
for (const origin of ['http://127.0.0.1:8888', 'https://localhost', 'http://[::1]:8888', 'https://dev.localhost']) {
assert.equal(helpers.isLoopbackServer(origin), true);
assert.match(helpers.networkFailureMessage({}, origin, true), /手机自身/);
}
for (const origin of ['https://im.bchongw.com', 'http://192.168.1.20:8888', 'https://localhost.example.com', 'https://127.0.0.1.example.com']) {
assert.equal(helpers.isLoopbackServer(origin), false);
}
assert.doesNotMatch(helpers.networkFailureMessage({}, 'http://127.0.0.1:8888', false), /手机自身/);
});
test('native network failures distinguish certificates, DNS and timeouts without exposing raw details', () => {
for (const [errMsg, expected] of [
['request:fail SSL handshake: Trust anchor not found', /证书/],
['request:fail java.net.UnknownHostException', /域名解析/],
['request:fail timeout', /超时/],
['request:fail Network is unreachable', /联网权限/],
]) {
assert.match(helpers.networkFailureMessage({ errMsg }, 'https://im.bchongw.com', true), expected);
}
const message = helpers.networkFailureMessage({ errMsg: 'request:fail https://example.com/callback?token=secret' }, 'https://im.bchongw.com', true);
assert.doesNotMatch(message, /token|secret/);
});
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = readFileSync(new URL('../src/utils/request.ts', import.meta.url), 'utf8');
const ast = ts.createSourceFile('request.ts', source, ts.ScriptTarget.Latest, true);
let api;
function visit(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(ast) === 'api') api = node.initializer;
ts.forEachChild(node, visit);
}
visit(ast);
assert.ok(api && ts.isObjectLiteralExpression(api));
function call(name, ...args) {
const property = api.properties.find(item => item.name?.getText(ast) === name);
const javascript = ts.transpileModule(`const call = ${property.initializer.getText(ast)};`, {
compilerOptions: { target: ts.ScriptTarget.ES2020 },
}).outputText;
return new Function('request', 'getDeviceId', `${javascript}; return call;`)(
(url, options) => ({ url, ...options }), () => 'test-device',
)(...args);
}
test('H5 login requests stay compatible with the deployed strict JSON API', () => {
assert.deepEqual(call('oauthStart', 'github', 'h5').data, { provider: 'github' });
assert.deepEqual(call('oauthExchange', 'code', '').data, { code: 'code', deviceId: 'test-device' });
assert.deepEqual(call('oauthLink', 'code', 'phone', '123456', '').data, {
code: 'code', deviceId: 'test-device', phone: 'phone', smsCode: '123456',
});
});
test('App-specific platform and proof are retained when supplied', () => {
assert.deepEqual(call('oauthStart', 'github', 'app').data, { provider: 'github', platform: 'app' });
assert.equal(call('oauthExchange', 'code', 'proof').data.appProof, 'proof');
assert.equal(call('oauthLink', 'code', 'phone', '123456', 'proof').data.appProof, 'proof');
});
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function loadOAuth({ api = {}, sdk = ['weixin', 'qq', 'google'], loginResult = {}, loginError } = {}) {
const storage = new Map();
const navigation = [];
const toasts = [];
const opened = [];
const loginOptions = [];
const runtime = { arguments: '', openURL: (url) => opened.push(url) };
const uni = {
getStorageSync: (key) => storage.get(key),
setStorageSync: (key, value) => storage.set(key, value),
removeStorageSync: (key) => storage.delete(key),
getProvider: ({ success }) => success({ provider: sdk }),
login: (options) => { loginOptions.push(options); loginError ? options.fail(loginError) : options.success(loginResult); },
reLaunch: ({ url }) => navigation.push(url),
navigateTo: ({ url }) => navigation.push(url),
showToast: ({ title }) => toasts.push(title),
};
const source = readFileSync(new URL('../src/utils/oauth.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS } }).outputText;
const exports = {};
// APP-PLUS functions return before their H5 branch, so this executes the real
// native control flow with only the SDK and network boundary substituted.
new Function('exports', 'require', 'uni', 'globalThis', js)(exports, () => ({ api }), uni, { plus: { runtime } });
return { ...exports, storage, navigation, toasts, opened, loginOptions, runtime };
}
test('App buttons are the intersection of backend switches and packaged SDKs', async () => {
const flow = loadOAuth({
sdk: ['weixin'],
api: { oauthProviders: async (platform) => { assert.equal(platform, 'app'); return { items: [{ code: 'wechat' }, { code: 'qq' }, { code: 'github' }] }; } },
});
assert.deepEqual((await flow.availableOAuthProviders()).map((p) => p.code), ['wechat', 'github']);
});
test('WeChat sends only its authorization code to the backend and enters the shared binding page', async () => {
const flow = loadOAuth({ loginResult: { code: 'native-code' }, api: {
oauthNative: async (...args) => { assert.deepEqual(args, ['wechat', 'native-code', '']); return { oauthCode: 'one-time-ticket' }; },
} });
await flow.beginOAuth({ code: 'wechat' });
assert.equal(flow.loginOptions[0].provider, 'weixin');
assert.equal(flow.loginOptions[0].onlyAuthorize, true);
assert.match(flow.navigation[0], /oauth-callback\?oauthCode=one-time-ticket$/);
});
test('QQ and Google exchange real SDK tokens, never client-supplied profile identities', async () => {
for (const code of ['qq', 'google']) {
const flow = loadOAuth({ loginResult: { authResult: { access_token: 'sdk-token', openid: 'untrusted-id' } }, api: {
oauthNative: async (...args) => { assert.deepEqual(args, [code, '', 'sdk-token']); return { oauthCode: 'ticket' }; },
} });
await flow.beginOAuth({ code });
assert.equal(flow.navigation.length, 1);
}
});
test('SDK cancellation does not call the login API', async () => {
const flow = loadOAuth({ loginError: { errMsg: 'login:fail cancel' } });
await assert.rejects(flow.beginOAuth({ code: 'qq' }), /已取消授权/);
assert.equal(flow.navigation.length, 0);
});
test('GitHub browser callback requires the pending request and preserves proof for exchange/binding', async () => {
const flow = loadOAuth({ api: { oauthStart: async (code, platform) => {
assert.equal(code, 'github'); assert.equal(platform, 'app');
return { authorizationUrl: 'https://github.com/login/oauth/authorize?state=request', appProof: 'private-proof', requestId: 'request', callbackUrl: 'xingyuim://oauth/callback' };
} } });
await flow.beginOAuth({ code: 'github' });
assert.equal(flow.opened.length, 1);
flow.runtime.arguments = 'xingyuim://oauth/callback?requestId=attacker&oauthCode=stolen';
flow.handleAppOAuthReturn();
assert.equal(flow.navigation.length, 0);
flow.runtime.arguments = 'xingyuim://oauth/callback?requestId=request&oauthCode=ticket';
assert.equal(flow.handleAppOAuthReturn(), true);
assert.equal(flow.appOAuthProofFor('ticket'), 'private-proof');
assert.equal(flow.appOAuthProofFor('different-ticket'), '');
assert.equal(flow.handleAppOAuthReturn(), false);
flow.clearAppOAuth();
assert.equal(flow.appOAuthProofFor('ticket'), '');
});
test('callback parser rejects foreign paths, duplicate fields and malformed encoding', () => {
const flow = loadOAuth();
for (const raw of [
'https://evil.example/oauth/callback?requestId=x',
'xingyuim://oauth/other?requestId=x',
'xingyuim://oauth/callback?requestId=x&requestId=y',
'xingyuim://oauth/callback?oauthCode=%ZZ',
'xingyuim://oauth/callback?requestId=x#fragment',
]) assert.equal(flow.parseAppOAuthCallback(raw), null);
});
@@ -0,0 +1,71 @@
// Isolated local visual harness: compiles the real AppIcon/BottomNav components.
// No login, production API, remote assets, or user data is used.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { parse, compileScript } from 'vue/compiler-sfc';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const root = new URL('../', import.meta.url);
const read = (path) => readFileSync(new URL(path, root), 'utf8');
const transpile = (content) => ts.transpileModule(content, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020 },
}).outputText;
const descriptors = new Map(['AppIcon', 'BottomNav'].map((name) => [
name, parse(read(`src/components/${name}.vue`), { filename: `${name}.vue` }).descriptor,
]));
const componentModule = (name) => transpile(compileScript(descriptors.get(name), {
id: `preview-${name}`, inlineTemplate: true,
templateOptions: { compilerOptions: { isCustomElement: (tag) => ['view', 'text'].includes(tag) } },
}).content).replace(/from ['"]vue['"]/g, 'from "/vue.js"')
.replace(/from ['"]\.\.\/utils\/icon-mask['"]/g, 'from "/icon-mask.js"')
.replace(/from ['"]\.\/AppIcon.vue['"]/g, 'from "/AppIcon.js"')
.replace(/from ['"]\.\.\/utils\/app-state['"]/g, 'from "/state.js"');
const css = [...descriptors.values()].flatMap((item) => item.styles.map((style) => style.content)).join('\n')
.replace(/(-?\d+(?:\.\d+)?)rpx/g, 'calc(var(--preview-rpx) * $1)');
// Include the actual App-vue button defaults: auto margins/overflow:hidden are
// essential to reproduce badge clipping (plain HTML buttons hide this bug).
const nativeCSS = read('node_modules/@dcloudio/uni-app-plus/dist/style.css');
const nativeButtonRule = [...nativeCSS.matchAll(/uni-button\{([^{}]+)\}/g)]
.find((match) => match[1].includes('margin-left:auto'))?.[1];
if (!nativeButtonRule) throw new Error('Native button defaults not found');
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>App 图标回归预览</title>
<style>button{${nativeButtonRule}}:root{--preview-rpx:calc(min(100vw,560px) / 750)}*{box-sizing:border-box}body{margin:0;padding:20px 18px 140px;max-width:560px;background:#141147;color:#fff;font:15px system-ui}view{display:block}button{border:0;font:inherit}button::after{border:0}h1{font-size:22px}p{color:#bcb5e1}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.sample{display:flex;flex-direction:column;align-items:center;gap:9px;padding:14px 2px;border-radius:12px;background:#ffffff0d}.sample label{font-size:11px}.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:16px 0}.controls button{padding:8px;background:#8660dd;color:white;border-radius:8px}.palette{font-size:24px;color:#ff83bb}.light{background:#fff;color:#17152d;padding:15px;border-radius:12px;display:flex;gap:20px;margin:16px 0}#preview-action{font-size:12px;color:#c4b1ff}${css}</style>
<div id="app"></div><script type="module">
import {createApp,h,ref} from '/vue.js';
import {ICON_PATHS} from '/icon-mask.js';
import {clientState} from '/state.js';
window.uni={navigateTo:({url})=>action.value=url,reLaunch:({url})=>action.value=url};
window.addEventListener('click',(event)=>{const button=event.target.closest('.nav-item');if(button)button.dispatchEvent(new Event('tap'));});
const action=ref('仅图标预览,不连接任何业务接口');
const {default:AppIcon}=await import('/AppIcon.js');
const {default:BottomNav}=await import('/BottomNav.js');
createApp({setup(){const filled=ref(false),pink=ref(false);return()=>h('main',{},[
h('h1','App 图标回归预览'),h('p','实际 AppIcon / BottomNav 组件 · 图标内置,无外部图片请求'),
h('div',{class:'controls'},[h('button',{onClick:()=>filled.value=!filled.value},filled.value?'切换空心':'切换实心'),h('button',{onClick:()=>pink.value=!pink.value},'切换颜色')]),
h('div',{class:'controls'},[0,1,9,99,100].map(count=>h('button',{onClick:()=>clientState.unreadMessages=count},'未读 '+count))),
h('div',{class:'grid',style:{color:pink.value?'#ff83bb':'#c4b1ff'}},Object.keys(ICON_PATHS).map(name=>h('div',{class:'sample'},[h(AppIcon,{name,size:28,filled:filled.value}),h('label',name)]))),
h('div',{class:'light'},['back','pin','filter','search','heart'].map(name=>h(AppIcon,{name,size:24,filled:name==='heart'}))),
h('div',{class:'palette'},[h(AppIcon,{name:'heart',filled:true}),h(AppIcon,{name:'add',size:'2em'})]),
h('p',{id:'preview-action'},action.value),h(BottomNav,{active:'home'})
])}}).mount('#app');
</script></html>`;
const server = createServer((req, res) => {
const path = new URL(req.url, 'http://localhost').pathname;
const modules = {
'/vue.js': () => readFileSync(require.resolve('vue/dist/vue.runtime.esm-browser.js'), 'utf8'),
'/icon-mask.js': () => transpile(read('src/utils/icon-mask.ts')),
'/AppIcon.js': () => componentModule('AppIcon'),
'/BottomNav.js': () => componentModule('BottomNav'),
'/state.js': () => 'import {reactive} from "/vue.js";export const clientState = reactive({ unreadMessages: 2 });',
};
res.setHeader('Cache-Control', 'no-store');
if (path === '/') { res.setHeader('Content-Type', 'text/html;charset=utf-8'); res.end(html); }
else if (modules[path]) { res.setHeader('Content-Type', 'application/javascript;charset=utf-8'); res.end(modules[path]()); }
else { res.statusCode = 404; res.end('Not found'); }
});
server.listen(Number(process.env.ICON_PREVIEW_PORT || 5197), '127.0.0.1', () => {
console.log(`Icon preview: http://127.0.0.1:${server.address().port}/`);
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { parse } from 'vue/compiler-sfc';
function elements(node) {
return [node, ...(node.children || []).flatMap(elements)];
}
for (const [file, binding] of [
['pages/home/index.vue', 'user.isTest'],
['pages/nearby/index.vue', 'user.isTest'],
['pages/profile/index.vue', 'profile.isTest'],
['components/UserListItem.vue', 'user.isTest'],
]) {
test(`${file}: badge follows server test flag, not nickname or membership`, () => {
const source = readFileSync(new URL(`../src/${file}`, import.meta.url), 'utf8');
const { descriptor } = parse(source);
const badge = elements(descriptor.template.ast).find(node => node.tag === 'TestUserBadge');
assert.ok(badge, 'missing test-user badge');
const condition = badge.props.find(prop => prop.name === 'if')?.exp?.content;
assert.equal(condition, binding);
const variable = binding.split('.')[0];
const visible = new Function(variable, `return Boolean(${condition})`);
assert.equal(visible({ isTest: true, vip: false }), true);
assert.equal(visible({ isTest: false, vip: true, nickname: '测试名称' }), false);
assert.equal(visible({}), false);
});
}
test('badge explicitly says 测试用户', () => {
const source = readFileSync(new URL('../src/components/TestUserBadge.vue', import.meta.url), 'utf8');
const { descriptor } = parse(source);
assert.match(descriptor.template.content, /测试用户/);
});
@@ -0,0 +1,72 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnv } from "vite";
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
function validOrigin(value, protocol) {
try {
const url = new URL(value);
const host = url.hostname.toLowerCase();
const loopback = host === 'localhost' || host.endsWith('.localhost') || /^127\./.test(host) || host === '[::1]' || host === '0.0.0.0';
return url.protocol === protocol && !!host && !loopback && !url.username && !url.password && !url.search && !url.hash && url.pathname === '/';
} catch {
return false;
}
}
export function nativeConfigFailures(manifest, env) {
const failures = [];
// DCloud 分配的 AppID 包括 7 位后缀,不能固定要求 8 位。
// 此处仅检查格式;AppID 的归属由 HBuilderX/DCloud 打包服务校验。
if (!/^__UNI__[0-9A-F]{7,8}$/i.test(String(manifest.appid || ""))) {
failures.push("src/manifest.json 的 appid 必须填写 DCloud 分配的 AppID,请在 HBuilderX 基础配置中获取");
}
if (!manifest["app-plus"]?.distribute?.ios?.privacyDescription?.NSCameraUsageDescription) {
failures.push("缺少 iOS 相机隐私用途说明");
}
for (const moduleName of ["Camera", "Geolocation", "OAuth", "Payment", "Push", "Record"]) {
if (!(moduleName in (manifest["app-plus"]?.modules || {}))) failures.push(`缺少原生模块:${moduleName}`);
}
if (!validOrigin(env.VITE_API_ORIGIN, "https:")) {
failures.push("App 必须通过 VITE_API_ORIGIN 配置可从手机访问的 HTTPS 接口域名,不能使用 localhost/127.0.0.1,也不能带 /api 路径");
}
if (!validOrigin(env.VITE_WS_ORIGIN, "wss:")) {
failures.push("App 必须通过 VITE_WS_ORIGIN 配置可从手机访问的 WSS 消息域名,不能使用本机回环地址或带 /ws 路径");
}
const oauth = manifest['app-plus']?.distribute?.sdkConfigs?.oauth || {};
if (oauth.weixin) {
if (!/^wx[0-9a-z]+$/i.test(oauth.weixin.appid || '')) failures.push('微信登录 SDK 必须填写移动应用 AppID');
if (oauth.weixin.appsecret) failures.push('禁止把微信 AppSecret 打入客户端,请只在后台 App 微信密钥中配置');
}
if (oauth.qq && !/^\d+$/.test(oauth.qq.appid || '')) failures.push('QQ 登录 SDK 必须填写移动应用 AppID');
if (oauth.google?.clientid && !String(oauth.google.clientid).endsWith('.apps.googleusercontent.com')) {
failures.push('Google 登录 SDK 的 iOS Client ID 格式不正确,请参见 docs/app-oauth.md');
}
return failures;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const manifestPath = resolve(projectRoot, "src/manifest.json");
const source = readFileSync(manifestPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const modeIndex = process.argv.indexOf('--mode');
const mode = modeIndex < 0 ? 'production' : process.argv[modeIndex + 1];
if (!mode || !/^[a-z][a-z0-9_-]*$/i.test(mode) || mode === 'local') {
console.error('请通过 --mode development 或 --mode production 指定构建环境');
process.exit(1);
}
// Check the same mode that the compiler uses; checking production before a
// development build previously missed its absent API and WebSocket origins.
const env = { ...loadEnv(mode, projectRoot, "VITE_"), ...process.env };
const failures = nativeConfigFailures(JSON.parse(source), env);
if (failures.length) {
console.error(`原生打包配置未完成:\n- ${failures.join("\n- ")}`);
process.exit(1);
}
console.log("原生打包基础配置校验通过(证书和 AppID 归属仍需在 HBuilderX 中校验)");
console.log(`[App network] mode=${mode} API=${env.VITE_API_ORIGIN} WS=${env.VITE_WS_ORIGIN}`);
if (!Object.keys(JSON.parse(source)['app-plus']?.distribute?.sdkConfigs?.oauth || {}).length) {
console.warn('尚未打包微信/QQ/Google SDK:这些原生登录入口会隐藏。请按 docs/app-oauth.md 配置;GitHub 使用系统浏览器。');
}
}
@@ -0,0 +1,41 @@
// Inspect actual native resources, not merely the source template or H5 output.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const root = fileURLToPath(new URL('../', import.meta.url));
const output = resolve(root, process.argv[2] || 'dist/build/app');
const service = readFileSync(resolve(output, 'app-service.js'), 'utf8');
assert.ok(service.includes('data:image/svg+xml;charset=utf-8,'), 'App bundle is missing embedded icon data');
assert.ok(service.includes('-webkit-mask-image') && service.includes('mask-image'), 'App bundle is missing mask bindings');
const ast = ts.createSourceFile('app-service.js', service, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
function visit(node) {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
&& ['resolveComponent', 'createElementVNode', 'createVNode', 'createElementBlock'].includes(node.expression.name.text)
&& node.arguments.length > 0
&& ts.isStringLiteral(node.arguments[0]) && ['svg', 'path'].includes(node.arguments[0].text)) {
throw new Error(`Native bundle still contains an unsupported ${node.arguments[0].text} template element`);
}
ts.forEachChild(node, visit);
}
visit(ast);
for (const page of ['home', 'nearby', 'messages', 'feed', 'profile', 'chat', 'membership']) {
const css = readFileSync(resolve(output, `pages/${page}/index.css`), 'utf8');
assert.match(css, /\.app-icon\[/, `${page}: missing shared icon CSS`);
for (const declaration of ['background-color:currentColor', '-webkit-mask-size:100% 100%', 'mask-size:100% 100%']) {
assert.ok(css.includes(declaration), `${page}: missing ${declaration}`);
}
if (['home', 'nearby', 'messages', 'feed', 'profile'].includes(page)) {
const button = css.match(/\.nav-item\[[^\]]+\]\{([^}]+)\}/)?.[1];
for (const declaration of ['width:100%', 'margin:0', 'overflow:visible', 'line-height:1']) {
assert.ok(button?.split(';').includes(declaration), `${page}: bottom tab missing ${declaration}`);
}
const badge = css.match(/\.badge\[[^\]]+\]\{([^}]+)\}/)?.[1];
for (const declaration of ['display:flex', 'white-space:nowrap', 'pointer-events:none']) {
assert.ok(badge?.split(';').includes(declaration), `${page}: unread badge missing ${declaration}`);
}
}
}
console.log('App 图标产物检查通过:内置图形、WebKit 兼容样式、消息角标防裁切样式齐全,无原始 SVG 模板组件。');