480 lines
25 KiB
JavaScript
480 lines
25 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const Module = require('node:module');
|
|
const { test } = require('node:test');
|
|
const ts = require('typescript');
|
|
|
|
const root = path.join(__dirname, '..');
|
|
const element = (type, props = {}) => ({ type, props });
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
// Execute the real page/component with isolated hooks and API calls, without a browser.
|
|
function harness(relativePath, api = {}, componentName = 'default') {
|
|
let cursor = 0;
|
|
let props = {};
|
|
const slots = [];
|
|
const effects = [];
|
|
const forms = [];
|
|
const calls = { success: [], error: [], requests: [], updates: [], navigation: [], lists: [], collectionReads: [] };
|
|
const useState = (initial) => {
|
|
const index = cursor++;
|
|
if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial;
|
|
return [slots[index], (value) => { slots[index] = typeof value === 'function' ? value(slots[index]) : value; }];
|
|
};
|
|
const useMemo = (factory, deps) => {
|
|
const index = cursor++;
|
|
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index].deps[i]))) {
|
|
slots[index] = { value: factory(), deps };
|
|
}
|
|
return slots[index].value;
|
|
};
|
|
const react = {
|
|
useState, useMemo,
|
|
useCallback: (callback, deps) => useMemo(() => callback, deps),
|
|
useEffect(callback, deps) {
|
|
const index = cursor++;
|
|
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index].deps[i]))) {
|
|
slots[index]?.cleanup?.();
|
|
slots[index] = { deps };
|
|
effects.push(() => { slots[index].cleanup = callback(); });
|
|
}
|
|
},
|
|
};
|
|
const named = (name, children = []) => Object.assign(function Component() {},
|
|
{ displayName: name }, Object.fromEntries(children.map((child) => [child, `${name}.${child}`])));
|
|
const antd = Object.fromEntries(['Alert', 'Button', 'Card', 'Col', 'Drawer', 'Empty', 'Flex', 'InputNumber', 'Modal', 'Pagination', 'Row', 'Select', 'Space', 'Spin', 'Statistic', 'Table', 'Tag'].map((name) => [name, name]));
|
|
antd.Descriptions = named('Descriptions', ['Item']);
|
|
antd.Typography = named('Typography', ['Title', 'Text', 'Paragraph']);
|
|
antd.Input = named('Input', ['Password', 'TextArea']);
|
|
antd.Form = named('Form', ['Item']);
|
|
antd.Form.useForm = () => {
|
|
const [form] = useState(() => {
|
|
const instance = {
|
|
values: {},
|
|
setFieldsValue(values) { Object.assign(this.values, values); },
|
|
getFieldValue(key) { return this.values[key]; },
|
|
async validateFields() { return this.values; },
|
|
};
|
|
forms.push(instance);
|
|
return instance;
|
|
});
|
|
return [form];
|
|
};
|
|
antd.App = { useApp: () => ({ message: {
|
|
success: (value) => calls.success.push(value), error: (value) => calls.error.push(value),
|
|
} }) };
|
|
const modules = {
|
|
react,
|
|
'react/jsx-runtime': { jsx: element, jsxs: element, Fragment: 'Fragment' },
|
|
'@umijs/max': { useParams: () => ({ id: 'account-1' }), history: { push: (url) => calls.navigation.push(url) } },
|
|
'@ant-design/charts': { Line: 'Line' },
|
|
'@ant-design/icons': new Proxy({}, { get: (_, name) => String(name) }),
|
|
antd,
|
|
'@/services/api': {
|
|
getOne: async () => api.detail,
|
|
getList: async (options) => {
|
|
calls.lists.push(options);
|
|
if (options.resource === 'creator-works') {
|
|
if (api.workListError) throw api.workListError;
|
|
return api.workPages?.[options.page] || { data: [], total: 0, hasNext: false };
|
|
}
|
|
if (options.resource === 'creator-comments') return { data: api.comments || [], total: (api.comments || []).length };
|
|
return { data: options.resource === 'network-exits' ? (api.exits || []) : [api.profile || {}] };
|
|
},
|
|
creatorGet: async (url) => {
|
|
if (url.endsWith('/metrics')) {
|
|
if (api.metricsError) throw api.metricsError;
|
|
return api.metrics || [];
|
|
}
|
|
calls.collectionReads.push(url);
|
|
if (api.collectionStatusError) throw api.collectionStatusError;
|
|
return api.collectionStatus || {};
|
|
},
|
|
creatorAction: async () => {}, accountAction: async () => {},
|
|
creatorUpdate: async (url, payload) => { calls.updates.push({ url, payload }); return api.updateResult || payload; },
|
|
...api.services,
|
|
},
|
|
'@/requestErrorConfig': {
|
|
jsonOptions: (method, body) => ({ method, body: JSON.stringify(body) }),
|
|
request: async (url, options) => { calls.requests.push({ url, options }); if (api.requestError) throw api.requestError; return api.requestResult; },
|
|
},
|
|
'@/utils/helpers': {
|
|
accountReadiness: () => ({ ready: true, canResume: true, label: '就绪' }),
|
|
conflictMessage: (error) => error.message,
|
|
dateTime: (value) => value, platformLabel: () => '抖音',
|
|
},
|
|
'@/components/PageActions': {
|
|
usePageInfo: (tree) => { calls.info = tree; },
|
|
usePageActions: (tree) => { calls.actions = tree; },
|
|
},
|
|
'@/components/accounts/RebindEnvironment': { default: 'RebindEnvironment', __esModule: true },
|
|
'@/components/creator/WorksGallery': { default: 'WorksGallery', __esModule: true },
|
|
'@/components/accounts/AccountLoginModal': { default: 'AccountLoginModal', __esModule: true },
|
|
'@/components/accounts/FingerprintFields': {
|
|
__esModule: true, default: 'FingerprintFields',
|
|
fingerprintFormValues: (fingerprint) => ({ fingerprint }), fingerprintPayload: (value) => value,
|
|
},
|
|
};
|
|
const filename = path.join(root, relativePath);
|
|
const loaded = new Module(filename);
|
|
loaded.filename = filename;
|
|
loaded.require = (name) => {
|
|
assert.ok(name in modules, `Unexpected import: ${name}`);
|
|
return modules[name];
|
|
};
|
|
loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
|
fileName: filename, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText + (componentName === 'default' ? '' : `\nexports[${JSON.stringify(componentName)}] = ${componentName};`), filename);
|
|
return {
|
|
calls, forms,
|
|
render(nextProps = props) {
|
|
props = nextProps;
|
|
cursor = 0;
|
|
const tree = loaded.exports[componentName](props);
|
|
while (effects.length) effects.shift()();
|
|
return tree;
|
|
},
|
|
async mount(nextProps) { this.render(nextProps); await flush(); return this.render(); },
|
|
unmount() { slots.forEach((slot) => slot?.cleanup?.()); },
|
|
};
|
|
}
|
|
|
|
function findAll(tree, predicate) {
|
|
if (!tree || typeof tree !== 'object') return [];
|
|
if (Array.isArray(tree)) return tree.flatMap((child) => findAll(child, predicate));
|
|
return [...(predicate(tree) ? [tree] : []), ...findAll(tree.props?.children, predicate), ...findAll(tree.props?.extra, predicate)];
|
|
}
|
|
const byType = (tree, type) => findAll(tree, (node) => node.type === type);
|
|
const card = (tree, title) => findAll(tree, (node) => node.type === 'Card' && node.props.title === title)[0];
|
|
const text = (tree) => {
|
|
if (tree == null) return '';
|
|
if (Array.isArray(tree)) return tree.map(text).join('');
|
|
return typeof tree === 'object' ? text(tree.props?.children) : String(tree);
|
|
};
|
|
const detailPath = 'src/pages/accounts/$id/index.tsx';
|
|
const editPath = 'src/pages/accounts/$id/edit.tsx';
|
|
const rebindPath = 'src/components/accounts/RebindEnvironment.tsx';
|
|
const binding = { alias: 'environment/1', name: '环境一', gateway: 'gateway-1', network_exit_id: 'exit-current', network_exit_health: 'healthy', binding_version: 3, runtime_id: 'runtime-1', fingerprint: { seed: 42 } };
|
|
const detail = { id: 'account-1', name: '账号一', platform: 'douyin', platform_account_key: '123', runtime_status: 'active', version: 2, tags: ['测试'], environment: binding };
|
|
const profile = { ...detail, note: '原备注', cooldown_seconds: 60 };
|
|
|
|
test('owned account metrics are one horizontal row with each number above its label', async () => {
|
|
const page = harness(detailPath, { detail, metrics: [{ follower_count: 12500, following_count: 0, total_favorited: null, friend_count: 8, aweme_count: 100 }] });
|
|
const tree = await page.mount();
|
|
const dataCard = card(tree, '账号数据');
|
|
const row = byType(dataCard, 'Flex').find((node) => byType(node, 'Statistic').length === 5 && !node.props.vertical);
|
|
assert.ok(row, 'all five metrics must share a horizontal Flex');
|
|
assert.notEqual(row.props.wrap, 'wrap');
|
|
const cells = byType(row, 'Flex').filter((node) => node.props.vertical);
|
|
assert.equal(cells.length, 5);
|
|
const labels = ['粉丝', '关注', '获赞', '互关', '作品总数'];
|
|
const values = ['1.3w', '0', '—', '8', '100'];
|
|
cells.forEach((cell, index) => {
|
|
const [number, label] = cell.props.children;
|
|
assert.equal(number.type, 'Statistic');
|
|
assert.equal(number.props.value, values[index]);
|
|
assert.equal(text(label), labels[index]);
|
|
});
|
|
assert.equal(byType(dataCard, 'Descriptions.Item').length, 0);
|
|
});
|
|
|
|
test('作品总数保留最近非空值,不会被下一次缺失字段的快照清空', async () => {
|
|
const page = harness(detailPath, { detail, metrics: [{ aweme_count: 100 }, { follower_count: 20, aweme_count: null }] });
|
|
const tree = await page.mount();
|
|
assert.equal(byType(card(tree, '账号数据'), 'Statistic')[4].props.value, '100');
|
|
});
|
|
|
|
test('详情作品总数使用完整采集历史,而非仅依赖最近指标记录', async () => {
|
|
const page = harness(detailPath, { detail, metrics: [{ follower_count: 20 }], collectionStatus: { work_count: 28, aweme_count: 100 } });
|
|
const tree = await page.mount();
|
|
assert.equal(byType(card(tree, '账号数据'), 'Statistic')[4].props.value, '100');
|
|
});
|
|
|
|
test('自有账号作品展示独立采集进度,历史作品可翻页且筛选不改变进度分母', async () => {
|
|
const api = {
|
|
collectionStatus: { work_count: 28, aweme_count: 100, works: { status: 'succeeded' } },
|
|
workPages: {
|
|
1: { data: [{ id: 'new-work' }], total: 28, hasNext: true },
|
|
2: { data: [{ id: 'historical-work' }], total: 28, hasNext: false },
|
|
},
|
|
};
|
|
const page = harness('src/components/creator/WorksGallery.tsx', api);
|
|
let tree = await page.mount({ sourceId: 'account/1', sourceType: 'owned' });
|
|
assert.match(text(tree), /已采集 28 \/ 总计 100/);
|
|
assert.match(text(tree), /尚未采集完整/);
|
|
assert.equal(page.calls.collectionReads[0], '/creator/accounts/account%2F1/collection-status');
|
|
assert.equal(byType(tree, 'Pagination')[0].props.total, 28);
|
|
byType(tree, 'Pagination')[0].props.onChange(2);
|
|
page.render();
|
|
await flush();
|
|
tree = page.render();
|
|
assert.equal(byType(tree, 'Pagination')[0].props.current, 2);
|
|
assert.equal(page.calls.lists.at(-1).page, 2);
|
|
assert.equal(page.calls.lists.at(-1).filters.source_type, 'owned');
|
|
assert.equal(byType(tree, 'Col')[0].props.children.props.cover.props.workID, 'historical-work');
|
|
api.workPages[1] = { data: [], total: 0, hasNext: false };
|
|
byType(tree, 'InputNumber')[0].props.onChange(10);
|
|
page.render();
|
|
await flush();
|
|
tree = page.render();
|
|
assert.equal(page.calls.lists.at(-1).page, 1);
|
|
assert.equal(page.calls.lists.at(-1).filters.min_likes_growth, 10);
|
|
assert.match(text(tree), /已采集 28 \/ 总计 100/);
|
|
assert.match(text(tree), /符合筛选 0 个作品/);
|
|
});
|
|
|
|
test('作品进度区分未知、零作品与数量不一致,不把采集成功当作完整', async () => {
|
|
for (const [count, total, expected] of [[7, null, /总计 未知/], [0, 0, /已采集 0 \/ 总计 0/], [7, 3, /数量与抖音总数不一致/]]) {
|
|
const page = harness('src/components/creator/WorksGallery.tsx', { collectionStatus: { work_count: count, aweme_count: total, works: { status: 'succeeded' } } });
|
|
const tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
|
|
assert.match(text(tree), expected);
|
|
if (total === null) assert.match(text(tree), /暂无法判断是否完整/);
|
|
}
|
|
});
|
|
|
|
test('读取作品进度失败明确提示,已采集作品仍然展示,刷新能重试', async (t) => {
|
|
t.mock.method(console, 'error', () => {});
|
|
const api = { collectionStatusError: new Error('进度读取失败'), workPages: { 1: { data: [{ id: 'work-1' }], total: 1, hasNext: false } } };
|
|
const page = harness('src/components/creator/WorksGallery.tsx', api);
|
|
let tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
|
|
assert.equal(byType(tree, 'Col').length, 1);
|
|
assert.equal(byType(tree, 'Alert')[0].props.title, '进度读取失败');
|
|
assert.match(text(tree), /采集进度暂不可用/);
|
|
api.collectionStatusError = null;
|
|
api.collectionStatus = { work_count: 1, aweme_count: 1, works: { status: 'succeeded' } };
|
|
await findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
|
|
tree = page.render();
|
|
assert.equal(byType(tree, 'Alert').length, 0);
|
|
assert.match(text(tree), /已采集 1 \/ 总计 1/);
|
|
assert.equal(page.calls.collectionReads.length, 2);
|
|
});
|
|
|
|
test('监控账号仍展示已采集列表,不请求自有账号采集进度', async () => {
|
|
const page = harness('src/components/creator/WorksGallery.tsx');
|
|
const tree = await page.mount({ sourceId: 'monitor-1', sourceType: 'competitor' });
|
|
assert.equal(page.calls.collectionReads.length, 0);
|
|
assert.match(text(tree), /共 0 个作品/);
|
|
});
|
|
|
|
test('作品列表读取失败明确提示并可重试,不影响独立采集进度', async (t) => {
|
|
t.mock.method(console, 'error', () => {});
|
|
const api = { workListError: new Error('作品读取失败'), collectionStatus: { work_count: 28, aweme_count: 100, works: { status: 'running' } } };
|
|
const page = harness('src/components/creator/WorksGallery.tsx', api);
|
|
let tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
|
|
assert.equal(byType(tree, 'Alert')[0].props.title, '作品读取失败');
|
|
assert.match(text(tree), /正在采集/);
|
|
api.workListError = null;
|
|
await byType(tree, 'Alert')[0].props.action.props.onClick();
|
|
tree = page.render();
|
|
assert.equal(byType(tree, 'Alert').length, 0);
|
|
for (const status of ['failed', 'blocked']) {
|
|
api.collectionStatus.works = { status, last_error: '浏览器未登录' };
|
|
await findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
|
|
tree = page.render();
|
|
assert.match(text(tree), /采集未完成:浏览器未登录/);
|
|
}
|
|
});
|
|
|
|
test('作品抽屉读取该作品的指标与评论,评论翻页保留每页条数', async () => {
|
|
const work = { id: 'work/1', title: '历史作品', description: '作品正文', published_at: '2025-01-01', likes_growth: 10, likes: 100 };
|
|
const page = harness('src/components/creator/WorksGallery.tsx', {
|
|
workPages: { 1: { data: [work], total: 1, hasNext: false } },
|
|
metrics: [{ collected_at: '2025-01-01', likes: 100 }],
|
|
comments: [{ id: 'comment-1', author_name: '作者', published_at: '2025-01-01', content: '评论正文' }],
|
|
});
|
|
let tree = await page.mount({ sourceType: 'competitor', sourceId: 'monitor-1' });
|
|
await byType(tree, 'Col')[0].props.children.props.onClick();
|
|
await flush();
|
|
tree = page.render();
|
|
const tables = byType(tree, 'Table');
|
|
assert.equal(tables[0].props.dataSource[0].likes, 100);
|
|
assert.equal(tables[1].props.dataSource[0].content, '评论正文');
|
|
for (const column of tables[0].props.columns) {
|
|
if (column.render && column.dataIndex !== 'collected_at') {
|
|
assert.ok(column.render(0) != null);
|
|
assert.ok(column.render(null) != null);
|
|
}
|
|
}
|
|
assert.match(text(tables[1].props.columns[0].render(null, {})), /—/);
|
|
assert.match(text(tables[1].props.columns[0].render(null, { author_name: '作者', published_at: '2025-01-01' })), /作者/);
|
|
tables[1].props.pagination.onChange(2, 20);
|
|
page.render();
|
|
await flush();
|
|
tree = page.render();
|
|
assert.equal(byType(tree, 'Table')[1].props.pagination.current, 2);
|
|
assert.equal(byType(tree, 'Table')[1].props.pagination.pageSize, 20);
|
|
assert.equal(page.calls.lists.at(-1).filters.work_id, 'work/1');
|
|
assert.equal(page.calls.lists.at(-1).pageSize, 20);
|
|
byType(tree, 'Drawer')[0].props.onClose();
|
|
assert.equal(byType(page.render(), 'Drawer')[0].props.open, false);
|
|
});
|
|
|
|
test('作品统计缺失时使用明确占位,增长曲线保留零值并排除未知值', async () => {
|
|
const stats = harness('src/components/creator/WorksGallery.tsx', {}, 'WorkStatsGrid');
|
|
const statsTree = await stats.mount({ work: { play_count: 0, likes: 100 } });
|
|
assert.match(text(statsTree), /0/);
|
|
assert.match(text(statsTree), /100/);
|
|
assert.match(text(statsTree), /—/);
|
|
const chart = harness('src/components/creator/WorksGallery.tsx', {}, 'GrowthChart');
|
|
let tree = await chart.mount({ metrics: [] });
|
|
assert.equal(tree, null);
|
|
tree = await chart.mount({ metrics: [{ collected_at: '2025-01-01', play_count: 0, likes: 100, shares: null }] });
|
|
assert.equal(tree.props.data.length, 2);
|
|
assert.equal(tree.props.data[0].value, 0);
|
|
assert.equal(tree.props.yAxis.labelFormatter(12000), '1.2w');
|
|
assert.equal(tree.props.yAxis.labelFormatter(0), '0');
|
|
});
|
|
|
|
test('all fixed resources live in account status and detail has no exit switching', async () => {
|
|
const page = harness(detailPath, { detail });
|
|
const tree = await page.mount();
|
|
assert.equal(card(tree, '固定资源'), undefined);
|
|
const status = card(tree, '账号状态');
|
|
for (const label of ['运行环境', '网关', '网络出口', '绑定版本', '运行实例', '不可调度原因']) {
|
|
assert.equal(byType(status, 'Descriptions.Item').filter((node) => node.props.label === label).length, 1);
|
|
}
|
|
assert.match(text(status), /gateway-1/);
|
|
assert.match(text(status), /exit-current/);
|
|
assert.equal(byType(tree, 'RebindEnvironment').length, 0);
|
|
});
|
|
|
|
test('detail retains missing data, no-environment state, trends, and account actions', async () => {
|
|
const page = harness(detailPath, { detail: { ...detail, environment: null, runtime_status: 'paused' } });
|
|
let tree = await page.mount();
|
|
assert.match(text(card(tree, '账号状态')), /尚未绑定运行环境/);
|
|
assert.deepEqual(byType(card(tree, '账号数据'), 'Statistic').map((node) => node.props.value), ['—', '—', '—', '—', '—']);
|
|
await byType(card(tree, '账号状态'), 'Button')[0].props.onClick();
|
|
assert.equal(page.calls.requests[0].url, '/phase-a/accounts/account-1/start');
|
|
await byType(page.calls.actions, 'Button')[1].props.onClick();
|
|
byType(page.calls.actions, 'Button')[2].props.onClick();
|
|
assert.deepEqual(page.calls.navigation, ['/accounts/account-1/edit']);
|
|
|
|
const active = harness(detailPath, { detail, metrics: [{ collected_at: 't1', follower_count: 1 }, { collected_at: 't2', follower_count: 2 }] });
|
|
tree = await active.mount();
|
|
const trend = findAll(card(tree, '账号数据'), (node) => typeof node.type === 'function' && node.type.name === 'AccountTrendChart')[0];
|
|
const chart = trend.type(trend.props);
|
|
assert.equal(chart.props.data.length, 2);
|
|
assert.equal(chart.props.yAxis.labelFormatter(20000), '2.0w');
|
|
assert.equal(chart.props.yAxis.labelFormatter(20), '20');
|
|
byType(card(tree, '账号状态'), 'Button')[0].props.onClick();
|
|
tree = active.render();
|
|
assert.equal(byType(tree, 'Modal')[0].props.open, true);
|
|
await byType(tree, 'Modal')[0].props.onOk();
|
|
assert.equal(byType(active.render(), 'Modal')[0].props.open, false);
|
|
});
|
|
|
|
test('edit has an independent network exit card; switching does not reset unsaved forms', async () => {
|
|
const page = harness(editPath, { detail, profile });
|
|
let tree = await page.mount();
|
|
const exitCard = card(tree, '网络出口');
|
|
assert.ok(exitCard);
|
|
assert.match(text(exitCard), /exit-current/);
|
|
assert.match(text(exitCard), /运行中的环境/);
|
|
const rebind = byType(exitCard, 'RebindEnvironment')[0];
|
|
assert.equal(rebind.props.binding.alias, binding.alias);
|
|
page.forms[0].values.note = '未保存的备注';
|
|
page.forms[1].values.fingerprint = { seed: 42, user_agent: '未保存的指纹' };
|
|
const changed = { ...binding, network_exit_id: 'exit-next', binding_version: 4 };
|
|
await rebind.props.onRebound(changed);
|
|
tree = page.render();
|
|
assert.match(text(card(tree, '网络出口')), /exit-next/);
|
|
assert.equal(page.forms[0].values.note, '未保存的备注');
|
|
assert.equal(page.forms[1].values.fingerprint.user_agent, '未保存的指纹');
|
|
byType(card(tree, '网络出口'), 'RebindEnvironment')[0].props.onError(new Error('出口检测失败'));
|
|
assert.deepEqual(page.calls.error, ['出口检测失败']);
|
|
});
|
|
|
|
test('editing a missing environment shows an explanation without a switch', async () => {
|
|
const page = harness(editPath, { detail: { ...detail, environment: null }, profile });
|
|
const tree = await page.mount();
|
|
assert.match(text(card(tree, '网络出口')), /尚未绑定运行环境/);
|
|
assert.equal(byType(tree, 'RebindEnvironment').length, 0);
|
|
});
|
|
|
|
test('profile and fingerprint continue to save separately', async () => {
|
|
const page = harness(editPath, { detail, profile, updateResult: { ...profile, ...binding } });
|
|
let tree = await page.mount();
|
|
const profileForm = findAll(card(tree, '账号资料'), (node) => node.props?.onFinish)[0];
|
|
await profileForm.props.onFinish({ note: '新备注' });
|
|
assert.equal(page.calls.updates[0].url, '/creator/accounts/account-1/profile');
|
|
assert.equal(page.calls.updates[0].payload.cooldown_seconds, 60);
|
|
await byType(card(tree, '指纹浏览器环境'), 'Button')[0].props.onClick();
|
|
assert.equal(page.calls.updates[1].url, '/creator/accounts/account-1/fingerprint');
|
|
assert.deepEqual(page.calls.updates[1].payload, binding.fingerprint);
|
|
tree = page.render();
|
|
byType(page.calls.actions, 'Button')[1].props.onClick();
|
|
tree = page.render();
|
|
assert.equal(byType(tree, 'AccountLoginModal').length, 1);
|
|
byType(tree, 'AccountLoginModal')[0].props.onClose();
|
|
assert.equal(byType(page.render(), 'AccountLoginModal').length, 0);
|
|
});
|
|
|
|
test('switch uses the environment alias, selects the current exit, and returns updated binding', async () => {
|
|
const changed = { ...binding, network_exit_id: 'exit-next', binding_version: 4 };
|
|
const page = harness(rebindPath, { exits: [
|
|
{ id: 'exit-current', health_status: 'healthy' },
|
|
{ id: 'exit-next', health_status: 'healthy', observed_public_ip: '1.2.3.4' },
|
|
{ id: 'exit-bad', health_status: 'failed' },
|
|
], requestResult: { account_id: detail.id, environment: changed } });
|
|
// The switch returns the server's environment view, without another full-page reload.
|
|
page.calls.rebound = [];
|
|
let tree = await page.mount({ binding, onRebound: (result) => page.calls.rebound.push(result), onError: (error) => page.calls.error.push(error.message) });
|
|
assert.equal(byType(tree, 'Select')[0].props.value, 'exit-current');
|
|
assert.equal(byType(tree, 'Button')[0].props.disabled, true);
|
|
const options = byType(tree, 'Select')[0].props.options;
|
|
assert.deepEqual(options.map((item) => item.value), ['', 'exit-current', 'exit-next']);
|
|
byType(tree, 'Select')[0].props.onChange('exit-next');
|
|
tree = page.render();
|
|
assert.equal(byType(tree, 'Button')[0].props.disabled, false);
|
|
await byType(tree, 'Button')[0].props.onClick();
|
|
assert.equal(page.calls.requests[0].url, '/browsers/environment%2F1/rebind');
|
|
assert.equal(page.calls.requests[0].options.body, JSON.stringify({ network_exit_id: 'exit-next' }));
|
|
assert.deepEqual(page.calls.rebound, [changed]);
|
|
page.unmount();
|
|
});
|
|
|
|
test('unavailable exits and rejected switches stay visible and do not report success', async (t) => {
|
|
const logs = [];
|
|
t.mock.method(console, 'error', (...args) => logs.push(args));
|
|
const error = new Error('出口不可用');
|
|
const unavailable = harness(rebindPath, { services: { getList: async () => { throw error; } } });
|
|
const tree = await unavailable.mount({ binding, onRebound: () => assert.fail('must not succeed'), onError: (reason) => unavailable.calls.error.push(reason.message) });
|
|
assert.deepEqual(unavailable.calls.error, ['出口不可用']);
|
|
assert.equal(byType(tree, 'Select')[0].props.disabled, true);
|
|
assert.equal(byType(tree, 'Button')[0].props.disabled, true);
|
|
assert.match(text(tree), /出口状态未知/);
|
|
|
|
const failed = harness(rebindPath, { requestError: error });
|
|
let failedTree = await failed.mount({ binding, onRebound: () => assert.fail('must not succeed'), onError: (reason) => failed.calls.error.push(reason.message) });
|
|
byType(failedTree, 'Select')[0].props.onChange('');
|
|
failedTree = failed.render();
|
|
await byType(failedTree, 'Button')[0].props.onClick();
|
|
assert.deepEqual(failed.calls.error, ['出口不可用']);
|
|
assert.equal(byType(failed.render(), 'Button')[0].props.loading, false);
|
|
assert.equal(logs.length, 2);
|
|
assert.equal(logs[1][1].environmentAlias, binding.alias);
|
|
assert.equal(logs[1][1].networkExitID, '');
|
|
});
|
|
|
|
test('direct connection succeeds, but missing or mismatched environment responses never report success', async (t) => {
|
|
t.mock.method(console, 'error', () => {});
|
|
for (const environment of [{ ...binding, network_exit_id: '' }, undefined, { alias: 'wrong-environment' }]) {
|
|
const page = harness(rebindPath, { requestResult: { environment } });
|
|
const rebound = [];
|
|
let tree = await page.mount({ binding, onRebound: (result) => rebound.push(result), onError: (reason) => page.calls.error.push(reason.message) });
|
|
byType(tree, 'Select')[0].props.onChange('');
|
|
tree = page.render();
|
|
await byType(tree, 'Button')[0].props.onClick();
|
|
assert.equal(page.calls.requests[0].options.body, JSON.stringify({ network_exit_id: '' }));
|
|
if (environment?.alias === binding.alias) {
|
|
assert.deepEqual(rebound, [environment]);
|
|
assert.deepEqual(page.calls.error, []);
|
|
} else {
|
|
assert.deepEqual(rebound, []);
|
|
assert.match(page.calls.error[0], /运行环境不匹配/);
|
|
}
|
|
page.unmount();
|
|
}
|
|
});
|