mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-08 10:56:13 -08:00
670 lines
44 KiB
JavaScript
670 lines
44 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { after, before, test } from 'node:test';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { chromium } from 'playwright';
|
|
|
|
const base = process.env.WEB_TEST_URL || 'http://127.0.0.1:5198';
|
|
const screenshotDir = process.env.WEB_SCREENSHOT_DIR;
|
|
let browser;
|
|
let server;
|
|
|
|
before(async () => {
|
|
if (!process.env.WEB_TEST_URL) {
|
|
server = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', '5198', '--strictPort'], { stdio: 'pipe' });
|
|
for (let i = 0; i < 100; i++) {
|
|
if (server.exitCode !== null) throw new Error('Vite exited before starting');
|
|
if (await fetch(base).then(r => r.ok).catch(() => false)) break;
|
|
await delay(100);
|
|
}
|
|
}
|
|
browser = await chromium.launch({ headless: true });
|
|
if (screenshotDir) await mkdir(screenshotDir, { recursive: true });
|
|
});
|
|
|
|
after(async () => {
|
|
await browser?.close();
|
|
server?.kill();
|
|
});
|
|
|
|
const uuid = n => ({ part1: 0, part2: 0, part3: 0, part4: n });
|
|
const id = n => `00000000-0000-0000-0000-${n.toString(16).padStart(12, '0')}`;
|
|
|
|
function fixture() {
|
|
const machines = ['Amsterdam gateway', 'Build server', 'Office workstation', 'Storage node', 'Travel laptop', 'Windows desktop'].map((hostname, i) => ({
|
|
client_url: `tcp://192.0.2.${i + 10}:11010`,
|
|
info: { hostname, machine_id: uuid(i + 1), easytier_version: '2.4.5', report_time: '2026-09-13 12:00:00', running_network_instances: i === 0 ? [uuid(100)] : [] },
|
|
location: { country: 'Netherlands', region: '', city: 'Amsterdam' },
|
|
networks: [],
|
|
}));
|
|
const networks = ['Engineering', 'Home lab', 'Office network'].map((display_name, i) => ({
|
|
network_id: `network-${i}`, display_name, network_name: `team-${i}`, networking_method: i === 0 ? 'Gateway' : 'Manual',
|
|
online_member_count: 1, member_count: 2, network_secret: 'test-secret', peer_urls: i === 0 ? [] : ['tcp://192.0.2.1:11010'],
|
|
}));
|
|
const members = [{ member_id: id(101), device_id: id(1), hostname: 'Amsterdam gateway', hostname_override: null, virtual_ipv4: '10.126.126.1', online: true, running: true, version: '2.4.5', error_msg: null, runtime_virtual_ipv4: '10.126.126.1' }];
|
|
return { machines, networks, members, memberConfig: {}, aclPolicy: { default_action: 'allow', rules: [] }, credentials: [], temporaryPeers: [], nodeRoutes: [], nodePeers: [], nodeAclStats: [], loggerConfig: { level: 'INFO' }, writes: [], requests: [], failures: new Set(), gatewayDelay: 0, gatewayEnabled: true };
|
|
}
|
|
|
|
async function open(t, route = '/h', options = {}, configure = () => {}) {
|
|
const state = fixture();
|
|
configure(state);
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, ...options });
|
|
t.after(() => context.close());
|
|
await context.addInitScript(() => localStorage.setItem('lang', 'en'));
|
|
if (state.noRandomUUID) {
|
|
await context.addInitScript(() => {
|
|
// Remote HTTP exposes getRandomValues but not randomUUID.
|
|
Object.defineProperty(crypto, 'randomUUID', { value: undefined });
|
|
const getRandomValues = crypto.getRandomValues.bind(crypto);
|
|
window.secureRandomCalls = 0;
|
|
crypto.getRandomValues = values => {
|
|
window.secureRandomCalls++;
|
|
return getRandomValues(values);
|
|
};
|
|
});
|
|
}
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(10000);
|
|
const errors = [];
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
t.after(() => assert.deepEqual(errors, [], 'no uncaught browser errors'));
|
|
await page.route('**/api_meta.js', route => route.fulfill({
|
|
contentType: 'text/javascript',
|
|
body: `window.apiMeta = ${JSON.stringify({ api_host: state.apiHost ?? '' })};`,
|
|
}));
|
|
await page.route('**/api/v1/**', async route => {
|
|
const request = route.request();
|
|
const path = new URL(request.url()).pathname.replace('/api/v1', '');
|
|
const method = request.method();
|
|
const payload = request.postDataJSON();
|
|
state.requests.push({ path, method });
|
|
if (method !== 'GET') state.writes.push({ path, method, payload });
|
|
if (state.failures.has(path)) return route.fulfill({ status: 503, json: { message: 'Test unavailable' } });
|
|
let result = {};
|
|
if (path === '/summary') result = { device_count: state.machines.length };
|
|
else if (path === '/console-info') result = { username: 'test-user', config_server_protocol: 'udp', config_server_port: 22020, webhook_auth: state.externalConsole ?? false };
|
|
else if (path === '/machines') result = { machines: state.machines };
|
|
else if (path === '/networks/gateway-info') {
|
|
await delay(state.gatewayDelay);
|
|
result = { enabled: state.gatewayEnabled, peer_url: 'tcp://192.0.2.1:11010', relay_data: true };
|
|
} else if (path === '/networks') {
|
|
if (method === 'POST') {
|
|
result = { ...payload.settings, network_id: `created-${state.networks.length}`, network_secret: 'new-secret', member_count: 0, online_member_count: 0 };
|
|
state.networks.push(result);
|
|
} else result = { networks: state.networks };
|
|
} else if (/^\/networks\/[^/]+$/.test(path)) {
|
|
const network = state.networks.find(n => n.network_id === path.split('/')[2]);
|
|
if (method === 'PATCH') Object.assign(network, payload.settings, payload.network_secret ? { network_secret: payload.network_secret } : {});
|
|
if (method === 'DELETE') state.networks = state.networks.filter(n => n !== network);
|
|
result = network ?? {};
|
|
} else if (path.endsWith('/acl-policy')) {
|
|
if (method === 'PUT') state.aclPolicy = payload;
|
|
result = { policy: state.aclPolicy };
|
|
} else if (/^\/networks\/[^/]+\/members$/.test(path)) {
|
|
if (method === 'POST') payload.device_ids.forEach(device_id => state.members.push({ device_id, hostname: 'Added device', online: true }));
|
|
result = { members: state.members, temporary_peers: state.temporaryPeers };
|
|
} else if (/^\/networks\/[^/]+\/members\/[^/]+\/config$/.test(path)) {
|
|
if (method === 'PUT') state.memberConfig = payload.config;
|
|
result = method === 'GET' ? state.memberConfig : state.members[0];
|
|
} else if (/^\/networks\/[^/]+\/members\//.test(path)) {
|
|
const member = state.members.find(m => m.device_id === path.split('/').at(-1));
|
|
if (method === 'PATCH') Object.assign(member, payload);
|
|
if (method === 'DELETE') state.members = state.members.filter(m => m !== member);
|
|
result = member ?? {};
|
|
} else if (/^\/networks\/[^/]+\/credentials$/.test(path)) {
|
|
result = { credentials: state.credentials };
|
|
} else if (path.endsWith('/proxy-rpc')) {
|
|
if (payload.method_name === 'list_route') result = { routes: state.nodeRoutes };
|
|
else if (payload.method_name === 'list_peer') result = { peer_infos: state.nodePeers };
|
|
else if (payload.method_name === 'get_acl_stats') result = { acl_stats: { rules: state.nodeAclStats } };
|
|
else if (payload.method_name === 'get_logger_config') result = state.loggerConfig;
|
|
else if (payload.method_name === 'set_logger_config') state.loggerConfig = { level: ['DISABLED', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE'][payload.payload.level] };
|
|
else if (payload.method_name === 'show_node_info') result = { node_info: { config: '[instance]\nname = "Engineering"' } };
|
|
} else if (/\/machines\/[^/]+\/networks$/.test(path)) result = { running_inst_ids: [uuid(100)], disabled_inst_ids: [] };
|
|
else if (path.endsWith('/networks/metas')) result = { metas: { [id(100)]: { network_name: 'Engineering', config_permission: 7 } } };
|
|
else if (path.includes('/networks/info/')) result = { info: { map: { [id(100)]: { error_msg: 'Test device is reconnecting' } } } };
|
|
else if (path.includes('/networks/config/')) result = { instance_id: id(100), network_name: 'Engineering', hostname: 'Amsterdam gateway', networking_method: 'Standalone' };
|
|
await route.fulfill({ json: result });
|
|
});
|
|
await page.goto(`${base}/#${route}`);
|
|
await page.locator('.console-page').waitFor();
|
|
return { page, state, context };
|
|
}
|
|
|
|
async function refresh(page) {
|
|
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
|
}
|
|
|
|
test('overview uses real counts and recovers from partial refresh failures', async t => {
|
|
const { page, state } = await open(t);
|
|
await page.waitForFunction(() => document.querySelectorAll('.summary-value')[2]?.textContent.trim() === '3');
|
|
assert.deepEqual(await page.locator('.summary-value').allTextContents().then(values => values.map(s => s.trim())), ['6', '0', '3', '1']);
|
|
state.machines[0].online = true;
|
|
await refresh(page);
|
|
await page.waitForFunction(() => document.querySelectorAll('.summary-value')[1]?.textContent.trim() === '1');
|
|
assert.equal(await page.locator('.overview-columns section').first().locator('.preview-row').count(), 5);
|
|
state.failures.add('/networks');
|
|
state.machines.pop();
|
|
await refresh(page);
|
|
await page.getByText('Unable to refresh data.', { exact: false }).waitFor();
|
|
assert.equal((await page.locator('.summary-value').nth(2).textContent()).trim(), '3');
|
|
await page.waitForFunction(() => document.querySelector('.summary-value')?.textContent.trim() === '5');
|
|
state.failures.clear();
|
|
state.networks.pop();
|
|
await page.waitForFunction(() => document.querySelectorAll('.summary-value')[2]?.textContent.trim() === '2');
|
|
assert.equal(await page.getByText('Unable to refresh data.', { exact: false }).count(), 0);
|
|
});
|
|
|
|
test('enrollment command uses the console info response', async t => {
|
|
const { page } = await open(t);
|
|
await page.getByRole('button', { name: 'Device Enrollment', exact: true }).click();
|
|
await page.getByText('easytier-core --config-server udp://127.0.0.1:22020/test-user').waitFor();
|
|
});
|
|
|
|
for (const [apiHost, hostname] of [
|
|
['https://api.example.test:8443/', 'api.example.test'],
|
|
['http://[2001:db8::1]:8848/', '[2001:db8::1]'],
|
|
['.', '127.0.0.1'],
|
|
]) {
|
|
test(`enrollment command uses the configured API hostname for ${apiHost}`, async t => {
|
|
const { page } = await open(t, '/h', {}, state => { state.apiHost = apiHost; });
|
|
await page.getByRole('button', { name: 'Device Enrollment', exact: true }).click();
|
|
await page.getByText(`easytier-core --config-server udp://${hostname}:22020/test-user`).waitFor();
|
|
});
|
|
}
|
|
|
|
test('device search, sort, expansion and routed drawer survive reload and history', async t => {
|
|
const { page } = await open(t, '/h/deviceList');
|
|
const table = page.locator('.desktop-list');
|
|
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).waitFor();
|
|
await page.getByRole('textbox', { name: 'Search name or address' }).fill('192.0.2.10');
|
|
assert.equal(await table.locator('tbody > tr').count(), 1);
|
|
await page.getByRole('textbox', { name: 'Search name or address' }).fill('');
|
|
// 无静态 sortField:离线排最后的预排序是默认视图;点击 hostname 列头一次升序、再点一次降序
|
|
await page.getByRole('columnheader', { name: 'Hostname' }).click(); // 升序
|
|
await page.getByRole('columnheader', { name: 'Hostname' }).click(); // 降序
|
|
assert.match(await table.locator('tbody > tr').first().textContent(), /Windows desktop/);
|
|
await table.locator('tbody > tr').first().getByRole('button').first().click();
|
|
await table.locator('.device-details').waitFor();
|
|
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).click();
|
|
await page.locator('.console-device-drawer').waitFor();
|
|
assert.ok(page.url().endsWith(`/device/${id(1)}/${id(100)}`));
|
|
await page.reload();
|
|
await page.locator('.console-device-drawer h2').filter({ hasText: 'Amsterdam gateway' }).waitFor();
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForURL('**/#/h/deviceList');
|
|
await page.goBack();
|
|
await page.locator('.console-device-drawer').waitFor();
|
|
});
|
|
|
|
test('device list card view toggle renders cards and persists across reload', async t => {
|
|
const { page } = await open(t, '/h/deviceList');
|
|
const table = page.locator('.desktop-list');
|
|
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).waitFor();
|
|
assert.equal(await page.locator('.device-card').count(), 0);
|
|
await page.locator('.view-toggle .pi-th-large').click();
|
|
await page.locator('.device-card').first().waitFor();
|
|
assert.equal(await page.locator('.device-card').count(), 6);
|
|
assert.equal(await table.count(), 0);
|
|
await page.reload();
|
|
await page.locator('.device-card').first().waitFor();
|
|
assert.equal(await page.locator('.device-card').count(), 6);
|
|
await page.locator('.view-toggle .pi-table').click();
|
|
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).waitFor();
|
|
assert.equal(await table.locator('tbody > tr').count(), 6);
|
|
});
|
|
|
|
test('network creation retains gateway and advanced standalone modes', async t => {
|
|
const { page, state } = await open(t, '/h/networks');
|
|
await page.locator('.desktop-list').getByRole('button', { name: 'Engineering', exact: true }).waitFor();
|
|
await page.getByRole('textbox', { name: 'Search networks' }).fill('no-match');
|
|
await page.getByText('No matching results', { exact: true }).waitFor();
|
|
await page.getByRole('button', { name: 'Clear search' }).click();
|
|
await page.getByRole('button', { name: 'Create Network', exact: true }).click();
|
|
await page.getByRole('dialog').getByText('Secure Mode', { exact: true }).waitFor();
|
|
await page.getByRole('dialog').locator('label[for="create-secure-mode"] + .pi-question-circle').hover();
|
|
await page.getByText('Noise encrypted handshakes with identity verification; enables temporary credentials. Member instances restart on change').waitFor();
|
|
await page.locator('#network-display-name').fill('Research');
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
|
await page.waitForURL('**/networks/created-*');
|
|
assert.equal(state.writes.find(w => w.path === '/networks')?.payload.settings.networking_method, 'Gateway');
|
|
await page.getByRole('button', { name: 'Back to networks' }).click();
|
|
await page.getByRole('button', { name: 'Create Network', exact: true }).click();
|
|
await page.getByRole('dialog').getByRole('button', { name: /Advanced/ }).click();
|
|
await page.getByRole('dialog').locator('.p-button-danger:visible').click();
|
|
await page.locator('#network-display-name').fill('Isolated lab');
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
|
await page.waitForURL('**/networks/created-*');
|
|
assert.equal(state.writes.filter(w => w.path === '/networks').at(-1).payload.settings.networking_method, 'Standalone');
|
|
});
|
|
|
|
test('PublicServer settings retain discovery mode when renamed or edited to the gateway URL', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
|
Object.assign(state.networks[0], {
|
|
networking_method: 'PublicServer',
|
|
public_server_url: 'tcp://public.example:11010',
|
|
});
|
|
});
|
|
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
|
await page.locator('#settings-display-name').fill('Public network');
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('heading', { name: 'Public network', exact: true }).waitFor();
|
|
let settings = state.writes.filter(w => w.method === 'PATCH').at(-1).payload.settings;
|
|
assert.equal(settings.networking_method, 'PublicServer');
|
|
assert.equal(settings.public_server_url, 'tcp://public.example:11010');
|
|
assert.deepEqual(settings.peer_urls, []);
|
|
|
|
await page.locator('#settings-initial-nodes .url-input-full input.grow').fill('192.0.2.1');
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.waitForResponse(response => response.request().method() === 'GET' && response.url().endsWith('/networks/network-0'));
|
|
settings = state.writes.filter(w => w.method === 'PATCH').at(-1).payload.settings;
|
|
assert.equal(settings.networking_method, 'PublicServer');
|
|
assert.equal(settings.public_server_url, 'tcp://192.0.2.1:11010');
|
|
assert.deepEqual(settings.peer_urls, []);
|
|
});
|
|
|
|
test('network tabs preserve gateway settings and unsaved input across polling', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0');
|
|
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
|
await page.locator('#settings-display-name').fill('Engineering draft');
|
|
const count = state.requests.filter(r => r.path === '/networks/network-0').length;
|
|
for (let i = 0; i < 50 && state.requests.filter(r => r.path === '/networks/network-0').length <= count; i++) await delay(100);
|
|
assert.ok(state.requests.filter(r => r.path === '/networks/network-0').length > count, 'polling continues');
|
|
assert.equal(await page.locator('#settings-display-name').inputValue(), 'Engineering draft');
|
|
await page.getByRole('tab', { name: 'Members', exact: true }).click();
|
|
await page.locator('.desktop-list').getByText('Running', { exact: true }).waitFor();
|
|
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
|
assert.equal(await page.locator('#settings-display-name').inputValue(), 'Engineering draft');
|
|
await page.locator('#regenerate-secret').check();
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('heading', { name: 'Engineering draft' }).waitFor();
|
|
const saved = state.writes.find(w => w.method === 'PATCH');
|
|
assert.equal(saved.payload.settings.networking_method, 'Gateway');
|
|
assert.match(saved.payload.network_secret, /^[0-9a-f-]{36}$/);
|
|
await page.getByRole('button', { name: 'Delete Network', exact: true }).click();
|
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel', exact: true }).click();
|
|
assert.equal(state.writes.some(w => w.method === 'DELETE'), false);
|
|
await page.getByRole('button', { name: 'Delete Network', exact: true }).click();
|
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
|
await page.waitForURL('**/#/h/networks');
|
|
assert.equal(state.networks.length, 2);
|
|
});
|
|
|
|
test('ACL rules and network secrets use secure randomness without randomUUID', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0', {}, state => { state.noRandomUUID = true; });
|
|
assert.equal(await page.evaluate(() => typeof crypto.randomUUID), 'undefined');
|
|
await page.getByRole('tab', { name: 'Access Control', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Add Rule', exact: true }).click();
|
|
const editor = page.getByRole('complementary').filter({ has: page.locator('#acl-rule-name') });
|
|
await editor.locator('#acl-rule-name').fill('Allow ping');
|
|
for (const select of ['Search and choose the members that initiate access', 'Search and choose target members or subnets']) {
|
|
await editor.getByText(select, { exact: true }).click();
|
|
await page.getByRole('option', { name: 'All members', exact: true }).click();
|
|
await page.keyboard.press('Escape');
|
|
}
|
|
await editor.getByRole('checkbox', { name: 'ICMP', exact: true }).check();
|
|
await editor.getByRole('button', { name: 'Save Rule', exact: true }).click();
|
|
await editor.waitFor({ state: 'detached' });
|
|
await page.getByRole('button', { name: 'Save Policy', exact: true }).click();
|
|
await page.getByText('Access policy saved', { exact: true }).waitFor();
|
|
assert.match(state.aclPolicy.rules[0].id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
|
|
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
|
const randomCalls = await page.evaluate(() => window.secureRandomCalls);
|
|
await page.locator('#regenerate-secret').check();
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByText('Config Saved', { exact: true }).waitFor();
|
|
const saved = state.writes.find(write => write.method === 'PATCH');
|
|
assert.match(saved.payload.network_secret, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
assert.ok(await page.evaluate(() => window.secureRandomCalls) > randomCalls);
|
|
});
|
|
|
|
test('a late member configuration response cannot overwrite the next member', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
|
state.members.push({ ...state.members[0], member_id: id(102), device_id: id(2), hostname: 'Build server' });
|
|
});
|
|
let releaseFirst;
|
|
const firstResponse = new Promise(resolve => { releaseFirst = resolve; });
|
|
t.after(() => releaseFirst());
|
|
const firstPath = `/networks/network-0/members/${id(1)}/config`;
|
|
await page.route('**/networks/network-0/members/*/config', async route => {
|
|
if (route.request().method() !== 'GET') return route.fallback();
|
|
const first = route.request().url().endsWith(firstPath);
|
|
if (first) await firstResponse;
|
|
await route.fulfill({ json: { hostname: first ? 'configuration-a' : 'configuration-b' } });
|
|
});
|
|
const firstRequested = page.waitForRequest(request => request.url().endsWith(firstPath));
|
|
await page.getByRole('row').filter({ hasText: 'Amsterdam gateway' }).getByRole('button', { name: 'Edit Member' }).click();
|
|
await firstRequested;
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
|
await page.getByRole('row').filter({ hasText: 'Build server' }).getByRole('button', { name: 'Edit Member' }).click();
|
|
await page.getByRole('dialog').getByRole('tab', { name: 'Advanced Config' }).click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Advanced Settings', exact: true }).click();
|
|
await page.locator('#hostname').waitFor();
|
|
assert.equal(await page.locator('#hostname').inputValue(), 'configuration-b');
|
|
const lateResponse = page.waitForResponse(response => response.url().endsWith(firstPath));
|
|
releaseFirst();
|
|
await (await lateResponse).finished();
|
|
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
assert.equal(await page.locator('#hostname').inputValue(), 'configuration-b');
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
|
const saved = state.writes.find(write => write.method === 'PUT' && write.path.endsWith('/config'));
|
|
assert.equal(saved.path, `/networks/network-0/members/${id(2)}/config`);
|
|
assert.equal(saved.payload.config.hostname, 'configuration-b');
|
|
});
|
|
|
|
test('settings wait for gateway discovery before exposing the save action', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0', {}, state => { state.gatewayDelay = 1500; });
|
|
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
|
assert.equal(await page.getByRole('button', { name: 'Save', exact: true }).count(), 0);
|
|
await page.locator('#settings-display-name').waitFor();
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
for (let i = 0; i < 50 && !state.writes.some(w => w.method === 'PATCH'); i++) await delay(100);
|
|
assert.equal(state.writes.find(w => w.method === 'PATCH').payload.settings.networking_method, 'Gateway');
|
|
});
|
|
|
|
test('editing an automatic member address keeps automatic assignment', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
|
state.networks[0].virtual_cidr = '10.126.0.0/16';
|
|
Object.assign(state.members[0], { virtual_ipv4: null, allocated_ipv4: '10.126.126.7', runtime_virtual_ipv4: null, online: false, running: false });
|
|
});
|
|
await page.locator('.desktop-list').getByText('10.126.126.7/16', { exact: true }).waitFor();
|
|
await page.getByRole('row').filter({ hasText: 'Amsterdam gateway' }).getByRole('button', { name: 'Edit Member' }).click();
|
|
assert.equal(await page.locator('#edit-virtual-ipv4').inputValue(), '');
|
|
await page.locator('#edit-hostname-override').fill('automatic-member');
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
|
assert.equal(state.writes.find(write => write.method === 'PATCH').payload.virtual_ipv4, '');
|
|
});
|
|
|
|
test('member editing, adding and removal keep existing request semantics', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0');
|
|
await page.getByRole('row').filter({ hasText: 'Amsterdam gateway' }).getByRole('button', { name: 'Edit Member' }).click();
|
|
await page.locator('#edit-hostname-override').fill('gateway-west');
|
|
await page.locator('#edit-virtual-ipv4').fill('10.126.126.20');
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('row').filter({ hasText: 'gateway-west' }).waitFor();
|
|
assert.equal(state.members[0].virtual_ipv4, '10.126.126.20');
|
|
await page.getByRole('button', { name: 'Add Devices' }).click();
|
|
await page.getByRole('dialog').getByRole('row').filter({ hasText: 'Build server' }).getByRole('checkbox').check();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
|
await page.getByRole('row').filter({ hasText: 'Added device' }).waitFor();
|
|
assert.deepEqual(state.writes.find(w => w.method === 'POST' && w.path.endsWith('/members')).payload.device_ids, [id(2)]);
|
|
await page.getByRole('row').filter({ hasText: 'Added device' }).getByRole('button', { name: 'Remove Device' }).click();
|
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
|
await page.getByRole('row').filter({ hasText: 'Added device' }).waitFor({ state: 'detached' });
|
|
assert.equal(state.members.length, 1);
|
|
});
|
|
|
|
test('central member WireGuard clients stay editable through the member configuration', async t => {
|
|
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
|
state.memberConfig = {
|
|
proxy_cidrs: ['192.168.20.0/24'],
|
|
vpn_portal_config: {
|
|
enabled: true,
|
|
wireguard_listen: '0.0.0.0:22022',
|
|
wireguard_private_key: 'KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=',
|
|
clients: [{ name: 'phone', virtual_ip: '10.126.126.2/24', groups: [] }],
|
|
},
|
|
};
|
|
});
|
|
const key = state.memberConfig.vpn_portal_config.wireguard_private_key;
|
|
const edit = async () => {
|
|
await page.getByRole('button', { name: 'Edit Member' }).click();
|
|
await page.getByRole('dialog').getByRole('tab', { name: 'Advanced Config' }).click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Advanced Settings', exact: true }).click();
|
|
await page.locator('#vpn_portal_client_name_0').waitFor();
|
|
};
|
|
await edit();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Add device', exact: true }).click();
|
|
await page.locator('#vpn_portal_client_virtual_ip_1').fill('10.126.126.3/24');
|
|
const generatedName = await page.locator('#vpn_portal_client_name_1').inputValue();
|
|
assert.match(generatedName, /^device-[a-f0-9]{8}$/);
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
|
assert.equal(state.memberConfig.vpn_portal_config.clients.length, 2);
|
|
assert.equal(state.memberConfig.vpn_portal_config.wireguard_private_key, key);
|
|
assert.deepEqual(state.memberConfig.proxy_cidrs, ['192.168.20.0/24']);
|
|
|
|
await edit();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete device', exact: true }).first().click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
|
assert.deepEqual(state.memberConfig.vpn_portal_config.clients.map(client => client.name), [generatedName]);
|
|
assert.equal(state.writes.filter(write => write.method === 'PUT' && write.path.endsWith('/config')).length, 2);
|
|
assert.equal(state.writes.some(write => write.path.endsWith('/vpn-portal-clients') || write.payload?.method_name === 'patch_config'), false);
|
|
});
|
|
|
|
test('credentials tab and temporary devices render', async t => {
|
|
const { page } = await open(t, '/h/networks/network-0', {}, state => {
|
|
state.networks[0].secure_mode = true;
|
|
const peers = [
|
|
{
|
|
peer_id: 42, credential_id: 'cred-1234567890abcdef', credential_expiry_unix: 1893456000,
|
|
hostname: 'Visitor laptop', ipv4: '10.126.126.50', version: '2.4.5',
|
|
},
|
|
{
|
|
peer_id: 99, credential_id: 'cred-1234567890abcdef', credential_expiry_unix: 1893456000,
|
|
hostname: 'Visitor phone', ipv4: '10.126.126.51', version: '2.4.5',
|
|
},
|
|
];
|
|
state.credentials = [{
|
|
credential_id: 'cred-1234567890abcdef', credential_secret: 'test-credential-secret',
|
|
expiry_unix: 1893456000, reusable: true, online_peers: peers,
|
|
}];
|
|
state.temporaryPeers = peers;
|
|
});
|
|
await page.getByRole('tab', { name: 'Credentials', exact: true }).click();
|
|
await page.getByRole('cell', { name: /cred-1234/ }).waitFor();
|
|
await page.getByText('Visitor laptop').waitFor();
|
|
await page.getByText('Visitor phone').waitFor();
|
|
await page.getByRole('tab', { name: 'Members', exact: true }).click();
|
|
await page.getByText('Temporary Devices').waitFor();
|
|
await page.getByRole('cell', { name: /Visitor laptop/ }).waitFor();
|
|
await page.getByRole('cell', { name: /Visitor phone/ }).waitFor();
|
|
});
|
|
|
|
test('PublicServer credential exports use the configured public server', async t => {
|
|
const peer = 'tcp://public.example.test:11010';
|
|
const { page } = await open(t, '/h/networks/network-0', {}, state => {
|
|
Object.assign(state.networks[0], {
|
|
secure_mode: true, networking_method: 'PublicServer', public_server_url: peer,
|
|
peer_urls: [],
|
|
});
|
|
state.credentials = [{
|
|
credential_id: 'cred-1234567890abcdef', credential_secret: 'test-credential-secret',
|
|
expiry_unix: 1893456000, reusable: true, online_peers: [],
|
|
}];
|
|
});
|
|
await page.getByRole('tab', { name: 'Credentials', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Show join command', exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: 'Temporary device join command' });
|
|
assert.equal(await dialog.locator('pre').textContent(),
|
|
`easytier-core --network-name team-0 --secure-mode --credential test-credential-secret -p ${peer}`);
|
|
await dialog.getByRole('button', { name: 'Config file', exact: true }).click();
|
|
assert.equal(await dialog.locator('pre').textContent(), [
|
|
'[network_identity]', 'network_name = "team-0"', '',
|
|
'[[peer]]', `uri = "${peer}"`, '',
|
|
'[secure_mode]', 'enabled = true', 'local_private_key = "test-credential-secret"',
|
|
].join('\n'));
|
|
});
|
|
|
|
test('node detail gives empty peers a clear home and keeps actions separate', async t => {
|
|
const { page, state } = await open(t, `/h/networks/${id(100)}`, {}, state => { state.networks[0].network_id = id(100); });
|
|
await page.getByRole('button', { name: 'Node Detail' }).click();
|
|
const drawer = page.locator('.console-node-drawer');
|
|
await drawer.getByText('No other nodes yet').waitFor();
|
|
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
|
|
assert.equal(await drawer.getByRole('columnheader').count(), 0);
|
|
assert.ok(await drawer.locator('.node-drawer-empty').evaluate(el => el.getBoundingClientRect().height < 60));
|
|
assert.ok(await drawer.locator('.node-drawer-summary').evaluate(el => el.getBoundingClientRect().height < 60));
|
|
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '0', '0', '0']);
|
|
assert.equal(await drawer.locator('.node-drawer-title').textContent(), 'Amsterdam gateway');
|
|
assert.equal(await drawer.locator('.node-drawer-title').count(), 1);
|
|
if (screenshotDir) await drawer.screenshot({ path: `${screenshotDir}/node-detail-empty.png`, animations: 'disabled' });
|
|
await drawer.getByRole('tab', { name: 'Settings & actions' }).click();
|
|
await drawer.getByText('Log Level', { exact: true }).waitFor();
|
|
await drawer.getByRole('button', { name: 'Export Config' }).click();
|
|
await page.getByText('[instance]', { exact: false }).waitFor();
|
|
assert.ok(state.requests.some(r => r.path.endsWith('/proxy-rpc')));
|
|
await page.emulateMedia({ colorScheme: 'dark' });
|
|
await page.reload();
|
|
await page.getByRole('button', { name: 'Switch language' }).click();
|
|
await page.getByRole('button', { name: '节点详情' }).click();
|
|
await drawer.getByText('暂无其他节点').waitFor();
|
|
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
|
|
if (screenshotDir) await drawer.screenshot({ path: `${screenshotDir}/node-detail-empty-cn-dark.png`, animations: 'disabled' });
|
|
});
|
|
|
|
test('logger levels decode protobuf responses, persist selections and translate labels', async t => {
|
|
const labels = ['Disabled', 'Error', 'Warning', 'Info', 'Debug', 'Trace'];
|
|
const { page, state } = await open(t, `/h/networks/${id(100)}`, {}, state => { state.networks[0].network_id = id(100); });
|
|
const drawer = page.locator('.console-node-drawer');
|
|
const select = drawer.getByRole('combobox');
|
|
const openSettings = async () => {
|
|
await page.getByRole('button', { name: 'Node Detail', exact: true }).click();
|
|
await drawer.getByRole('tab', { name: 'Settings & actions', exact: true }).click();
|
|
};
|
|
// Disabled is omitted by protobuf JSON; named and numeric values are valid.
|
|
for (const [level, label] of [[undefined, 'Disabled'], ...labels.map(label => [label.toUpperCase(), label]), [3, 'Info']]) {
|
|
state.loggerConfig = level === undefined ? {} : { level };
|
|
await openSettings();
|
|
await select.getByText(label, { exact: true }).waitFor();
|
|
await page.keyboard.press('Escape');
|
|
await drawer.waitFor({ state: 'hidden' });
|
|
}
|
|
await openSettings();
|
|
for (const [level, label] of labels.entries()) {
|
|
await select.click();
|
|
assert.deepEqual(await page.getByRole('option').allTextContents(), labels);
|
|
await page.getByRole('option', { name: label, exact: true }).click();
|
|
await page.waitForFunction(() => !document.querySelector('.console-node-drawer .p-select').classList.contains('p-disabled'));
|
|
assert.equal(state.writes.filter(write => write.payload?.method_name === 'set_logger_config').at(-1).payload.payload.level, level);
|
|
await select.getByText(label, { exact: true }).waitFor();
|
|
await page.reload();
|
|
await openSettings();
|
|
await select.getByText(label, { exact: true }).waitFor();
|
|
}
|
|
await page.keyboard.press('Escape');
|
|
await drawer.waitFor({ state: 'hidden' });
|
|
await page.getByRole('button', { name: 'Switch language', exact: true }).click();
|
|
await page.getByRole('button', { name: '节点详情', exact: true }).click();
|
|
await drawer.getByRole('tab').nth(1).click();
|
|
await select.getByText('跟踪', { exact: true }).waitFor();
|
|
await select.click();
|
|
assert.deepEqual(await page.getByRole('option').allTextContents(), ['禁用', '错误', '警告', '信息', '调试', '跟踪']);
|
|
});
|
|
|
|
test('node detail groups populated peers, routes, connections and ACL stats', async t => {
|
|
const { page } = await open(t, `/h/networks/${id(100)}`, { viewport: { width: 390, height: 844 }, colorScheme: 'dark' }, state => {
|
|
state.networks[0].network_id = id(100);
|
|
state.nodeRoutes = [{ peer_id: 2, hostname: 'Build server', ipv4_addr: { address: { addr: 175005442 }, network_length: 24 }, cost: 1, path_latency: 8, proxy_cidrs: [], version: '2.4.5', next_hop_peer_id: 2 }];
|
|
state.nodePeers = [{ peer_id: 2, conns: [
|
|
{ conn_id: 'conn-1', tunnel: { tunnel_type: 'tcp', remote_addr: { url: 'tcp://192.0.2.11:11010' } }, stats: { latency_us: 8000, rx_bytes: 1024, tx_bytes: 2048 } },
|
|
{ conn_id: 'conn-2', tunnel: { tunnel_type: 'udp', remote_addr: { url: 'udp://192.0.2.11:11010' } }, stats: { latency_us: 9000 }, loss_rate: 0.125 },
|
|
] }];
|
|
state.nodeAclStats = [{ rule: { name: 'Allow build server' }, stat: { packet_count: 42, byte_count: 4096 } }];
|
|
});
|
|
await page.locator('.mobile-list').getByRole('button', { name: 'Node Detail' }).click();
|
|
const drawer = page.locator('.console-node-drawer');
|
|
await drawer.locator('.node-drawer-mobile-peer').getByText('Build server').waitFor();
|
|
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
|
|
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '1', '1', '2']);
|
|
assert.equal(await drawer.getByRole('tab').count(), 2);
|
|
const drawerWidth = await drawer.evaluate(el => ({ drawer: el.getBoundingClientRect().width, viewport: innerWidth }));
|
|
assert.ok(drawerWidth.drawer <= drawerWidth.viewport + 1, JSON.stringify(drawerWidth));
|
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
|
if (screenshotDir) await drawer.screenshot({ path: `${screenshotDir}/node-detail-populated-mobile-dark.png`, animations: 'disabled' });
|
|
await page.setViewportSize({ width: 1024, height: 844 });
|
|
await drawer.getByRole('cell', { name: 'Build server' }).waitFor();
|
|
await drawer.locator('details').filter({ hasText: 'Routes' }).locator('summary').click();
|
|
await drawer.getByText('10.110.95.2/24').first().waitFor();
|
|
await drawer.locator('details').filter({ hasText: 'Connections' }).locator('summary').click();
|
|
await drawer.getByRole('cell', { name: '0.0%', exact: true }).waitFor();
|
|
await drawer.getByRole('cell', { name: '12.5%', exact: true }).waitFor();
|
|
await drawer.locator('details').filter({ hasText: 'ACL Stats' }).locator('summary').click();
|
|
await drawer.getByRole('cell', { name: 'Allow build server' }).waitFor();
|
|
});
|
|
|
|
test('empty and failed lists recover without presenting an empty result as success', async t => {
|
|
const { page, state } = await open(t, '/h/deviceList');
|
|
state.machines = [];
|
|
await refresh(page);
|
|
await page.getByRole('heading', { name: 'No devices yet' }).waitFor();
|
|
state.failures.add('/machines');
|
|
await page.reload();
|
|
await page.getByRole('button', { name: 'Retry', exact: true }).waitFor();
|
|
assert.equal(await page.getByRole('heading', { name: 'No devices yet' }).count(), 0);
|
|
state.failures.clear();
|
|
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
|
await page.getByRole('heading', { name: 'No devices yet' }).waitFor();
|
|
});
|
|
|
|
test('responsive layouts, localization, dark mode and mobile drawer', async t => {
|
|
const { page, state } = await open(t);
|
|
const workspaceMenu = page.locator('.console-sidebar').getByRole('menu', { name: 'Navigation' });
|
|
await workspaceMenu.focus();
|
|
await page.keyboard.press('ArrowDown'); // Dashboard -> Device List
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForURL('**/#/h/deviceList');
|
|
await workspaceMenu.locator('[aria-current="page"]', { hasText: 'Device List' }).waitFor();
|
|
assert.equal(await workspaceMenu.locator('[aria-current="page"]').textContent(), 'Device List');
|
|
const networkMenu = page.locator('.console-sidebar').getByRole('menu', { name: 'Networks' });
|
|
await networkMenu.focus();
|
|
await page.keyboard.press('ArrowDown'); // Networks overview -> Engineering (first nav-child)
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForURL('**/networks/network-0');
|
|
await networkMenu.locator('[aria-current="page"]', { hasText: 'Engineering' }).waitFor();
|
|
for (const colorScheme of ['light', 'dark']) {
|
|
await page.emulateMedia({ colorScheme });
|
|
for (const width of [1440, 1024, 390]) {
|
|
await page.setViewportSize({ width, height: 960 });
|
|
for (const language of ['en', 'cn']) {
|
|
for (const [name, route] of [['overview', '/h'], ['devices', '/h/deviceList'], ['networks', '/h/networks'], ['members', '/h/networks/network-0']]) {
|
|
await page.goto(`${base}/#${route}`);
|
|
await page.locator('.console-page').waitFor();
|
|
if (await page.evaluate(() => localStorage.getItem('lang')) !== language) {
|
|
await page.getByRole('button', { name: /Switch language|切换语言/ }).click();
|
|
}
|
|
await page.locator('.p-skeleton').first().waitFor({ state: 'detached' });
|
|
assert.equal(await page.locator('.web-console').evaluate(el => getComputedStyle(el).backgroundColor), colorScheme === 'light' ? 'rgb(247, 248, 249)' : 'rgb(16, 23, 30)');
|
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${name} ${width} ${colorScheme} must not overflow`);
|
|
if (screenshotDir) await page.screenshot({ path: `${screenshotDir}/${name}-${language}-${colorScheme}-${width}.png`, fullPage: true, animations: 'disabled' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
state.machines[0].info.hostname = 'A-very-long-device-hostname-that-must-wrap-without-breaking-the-layout.example.internal';
|
|
await page.setViewportSize({ width: 390, height: 960 });
|
|
await page.goto(`${base}/#/h/deviceList`);
|
|
await page.locator('.mobile-list .entity-link').first().click();
|
|
await page.locator('.console-device-drawer').waitFor();
|
|
await page.waitForFunction(() => Math.abs(document.querySelector('.console-device-drawer').getBoundingClientRect().left) < 1);
|
|
assert.ok(await page.locator('.console-device-drawer').evaluate(el => el.getBoundingClientRect().width <= innerWidth));
|
|
if (screenshotDir) await page.screenshot({ path: `${screenshotDir}/device-drawer-mobile.png`, animations: 'disabled' });
|
|
await page.keyboard.press('Escape');
|
|
await page.locator('.mobile-nav-toggle').click();
|
|
await page.locator('.console-mobile-nav').getByRole('menu', { name: '导航' }).waitFor();
|
|
await page.locator('.console-mobile-nav').getByRole('link', { name: '设备列表', exact: true }).click();
|
|
await page.locator('.console-mobile-nav').waitFor({ state: 'detached' });
|
|
await page.locator('.mobile-nav-toggle').click();
|
|
await page.locator('.console-mobile-nav').getByRole('link').first().click();
|
|
await page.locator('.console-mobile-nav').waitFor({ state: 'detached' });
|
|
});
|
|
|
|
|
|
test('external Console keeps the device dashboard without central requests or navigation', async t => {
|
|
const { page, state } = await open(t, '/h', {}, state => {
|
|
state.externalConsole = true;
|
|
state.failures.add('/networks');
|
|
});
|
|
await page.getByText('Amsterdam gateway', { exact: true }).waitFor();
|
|
await delay(2500);
|
|
assert.equal(state.requests.some(request => request.path.startsWith('/networks')), false);
|
|
assert.equal(await page.locator('a[href*="/networks"]').count(), 0);
|
|
assert.equal(await page.locator('.p-message-warn').count(), 0);
|
|
});
|