kiku-agent 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -12
- package/package.json +2 -1
- package/server/agent/agentCore.js +319 -46
- package/server/agent/appPaths.js +31 -0
- package/server/agent/audioPlayer.js +339 -12
- package/server/agent/autoContext.js +193 -0
- package/server/agent/catalogEntityResolver.js +170 -28
- package/server/agent/clarifyingQuestions.js +137 -35
- package/server/agent/cli.js +177 -48
- package/server/agent/cloudAdapters.js +116 -0
- package/server/agent/commandRegistry.js +409 -80
- package/server/agent/commands.js +1056 -125
- package/server/agent/config.js +1088 -690
- package/server/agent/contextCompactor.js +213 -21
- package/server/agent/cpuMetrics.js +199 -0
- package/server/agent/engineService.js +591 -0
- package/server/agent/eval/modules/evaluation/runner.js +4 -14
- package/server/agent/eval/modules/evaluation/scoring.js +15 -3
- package/server/agent/eval/modules/server/evalApi.js +5 -5
- package/server/agent/eval/modules/ui/evalUi.js +7 -4
- package/server/agent/eventBus.js +1 -0
- package/server/agent/ink/main.jsx +196 -191
- package/server/agent/jobManager.js +11 -1
- package/server/agent/lanDiscovery.js +721 -23
- package/server/agent/llmClient.js +317 -71
- package/server/agent/lmStudioWakeup.js +517 -65
- package/server/agent/locationService.js +797 -0
- package/server/agent/mcpQuizData.js +509 -0
- package/server/agent/memory/contextAllocator.js +19 -7
- package/server/agent/memory.js +3 -1
- package/server/agent/modelCompatibility.js +352 -0
- package/server/agent/modelDefaults.js +1 -0
- package/server/agent/modelManager.js +46 -2
- package/server/agent/newsWatch.js +9 -3
- package/server/agent/processManager.js +229 -0
- package/server/agent/prompts.js +174 -8
- package/server/agent/routingInterceptor.js +9 -1
- package/server/agent/sanitizer.js +41 -6
- package/server/agent/searchQueryPlanner.js +202 -0
- package/server/agent/session.js +59 -4
- package/server/agent/tests/agentFollowUps.test.js +128 -2
- package/server/agent/tests/agentMusicControl.test.js +245 -0
- package/server/agent/tests/apiRouter.test.js +1 -1
- package/server/agent/tests/autoContext.test.js +176 -0
- package/server/agent/tests/bilibiliEmbed.test.js +45 -0
- package/server/agent/tests/chatHardening.test.js +121 -0
- package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
- package/server/agent/tests/clarifyingQuestions.test.js +33 -0
- package/server/agent/tests/cliAutomation.test.js +38 -1
- package/server/agent/tests/cloudProviders.test.js +91 -3
- package/server/agent/tests/commands.test.js +92 -3
- package/server/agent/tests/contextIntelligence.test.js +50 -3
- package/server/agent/tests/cpuMetrics.test.js +101 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
- package/server/agent/tests/engineService.test.js +436 -0
- package/server/agent/tests/engineUnload.test.js +94 -0
- package/server/agent/tests/envCommand.test.js +69 -0
- package/server/agent/tests/evalMetrics.test.js +8 -0
- package/server/agent/tests/fetchApi.test.js +99 -0
- package/server/agent/tests/hybridWebSearch.test.js +2 -0
- package/server/agent/tests/lanDiscovery.test.js +5 -0
- package/server/agent/tests/lanInterfaces.test.js +510 -0
- package/server/agent/tests/lmStudioWakeup.test.js +278 -0
- package/server/agent/tests/locationTool.test.js +278 -0
- package/server/agent/tests/memoryRoute.test.js +173 -0
- package/server/agent/tests/memoryUiNormalization.test.js +75 -0
- package/server/agent/tests/modelCompatibility.test.js +148 -0
- package/server/agent/tests/modelManager.test.js +32 -0
- package/server/agent/tests/musicControlUiBridge.test.js +113 -0
- package/server/agent/tests/nextGenArchitecture.test.js +4 -2
- package/server/agent/tests/openDomainPrompt.test.js +197 -0
- package/server/agent/tests/openDomainSearch.test.js +121 -0
- package/server/agent/tests/processManager.test.js +144 -0
- package/server/agent/tests/providerPersistence.test.js +10 -5
- package/server/agent/tests/quickActionsAndModals.test.js +180 -0
- package/server/agent/tests/searchQueryPlanner.test.js +108 -0
- package/server/agent/tests/securityAuditRemediation.test.js +16 -9
- package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
- package/server/agent/tests/securityHardening.test.js +4 -4
- package/server/agent/tests/sessionAndCopy.test.js +39 -4
- package/server/agent/tests/sidebarControls.test.js +34 -0
- package/server/agent/tests/statefulMemory.test.js +8 -1
- package/server/agent/tests/textProtocolLoop.test.js +71 -0
- package/server/agent/tests/textToolParsing.test.js +48 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
- package/server/agent/tests/unifiedEndpoints.test.js +13 -6
- package/server/agent/tests/v110Features.test.js +1 -1
- package/server/agent/tests/vramEstimator.test.js +75 -0
- package/server/agent/tests/webChatDocs.test.js +199 -0
- package/server/agent/tests/webChatInferenceControl.test.js +298 -0
- package/server/agent/tests/webChatInspection.test.js +256 -5
- package/server/agent/tests/webChatMcp.test.js +94 -0
- package/server/agent/tests/webExtract.test.js +23 -0
- package/server/agent/tests/webSources.test.js +46 -12
- package/server/agent/textToolProtocol.js +173 -46
- package/server/agent/toolRouter.js +318 -44
- package/server/agent/tools.js +877 -96
- package/server/agent/ui.js +39 -34
- package/server/agent/vramService.js +35 -54
- package/server/agent/webSearch.js +441 -162
- package/server/agent/websearch/orchestrator.js +15 -5
- package/server/agent/websearch/resilience/cache.js +9 -2
- package/server/agent/websearch/security/ssrf.js +41 -10
- package/server/agent/youtubeAuth.js +5 -20
- package/server/apiRouter.js +5558 -4422
- package/server/mcp/data/discography.js +2 -2
- package/server/mcp/examples/antigravity.example.json +17 -0
- package/server/mcp/handler.js +8 -4
- package/server/mcp/security.js +220 -21
- package/server/mcp/server.js +2 -1
- package/server/mcp/tests/connection.test.js +14 -8
- package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
- package/server/mcp/tools.js +7 -1
- package/server/supabase_migration_prefix.sql +102 -0
- package/server/supabase_schema.sql +18 -18
- package/server/web-chat/core/connection.js +179 -218
- package/server/web-chat/core/conversationContext.js +9 -2
- package/server/web-chat/core/directCatalogEngine.js +52 -28
- package/server/web-chat/core/dramaKnowledge.js +33 -22
- package/server/web-chat/core/followUpEngine.js +735 -81
- package/server/web-chat/core/futureEventGuard.js +5 -1
- package/server/web-chat/core/musicControlAction.js +70 -0
- package/server/web-chat/core/security.js +32 -21
- package/server/web-chat/core/toolInspector.js +6 -6
- package/server/web-chat/core/urlGuard.js +45 -0
- package/server/web-chat/router.js +204 -157
- package/server/web-chat/routes/chatRoute.js +885 -488
- package/server/web-chat/routes/engineRoute.js +270 -0
- package/server/web-chat/routes/exportRoute.js +1 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
- package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
- package/server/web-chat/routes/mcpRoute.js +386 -0
- package/server/web-chat/routes/memoryRoute.js +253 -0
- package/server/web-chat/routes/providerRoute.js +49 -16
- package/server/web-chat/routes/staticRoute.js +306 -17
- package/server/web-chat/routes/statusRoute.js +194 -73
- package/server/web-chat/routes/suggestRoute.js +11 -3
- package/server/web-chat/routes/verifyRoute.js +26 -4
- package/server/web-chat/server.js +7 -6
- package/server/web-chat/ui/docsBuilder.js +3558 -0
- package/server/web-chat/ui/htmlBuilder.js +12 -0
- package/server/web-chat/ui/scripts/client.js +3401 -1302
- package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
- package/server/web-chat/ui/settingsBuilder.js +7870 -0
- package/server/web-chat/ui/styles/lan-card.css +165 -0
- package/server/web-chat/ui/styles/main.css +4289 -447
- package/server/web-chat/ui/sw.js +64 -0
- package/server/web-chat/ui/templates/body.html +407 -281
- package/src/data/awardsData.js +146 -146
- package/src/data/musicVideoData.js +6 -4
- package/src/lib/vram/estimator.js +195 -19
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
clearLanScanCache,
|
|
6
|
+
classifyScanToken,
|
|
7
|
+
describeLanInterface,
|
|
8
|
+
describeLanScanAge,
|
|
9
|
+
enrichLinuxInterfaces,
|
|
10
|
+
enrichMacInterfaces,
|
|
11
|
+
enrichWindowsInterfaces,
|
|
12
|
+
expandCidrToHosts,
|
|
13
|
+
getLanCandidateSubnets,
|
|
14
|
+
inferInterfaceType,
|
|
15
|
+
listLanInterfaces,
|
|
16
|
+
loadLanState,
|
|
17
|
+
netmaskFromPrefix,
|
|
18
|
+
netmaskToPrefix,
|
|
19
|
+
networkCidr,
|
|
20
|
+
parseLanScanArgs,
|
|
21
|
+
parseLinuxDefaultRoutes,
|
|
22
|
+
parseLinuxIpAddr,
|
|
23
|
+
parseLinuxIpAddrText,
|
|
24
|
+
parseMacDefaultRoute,
|
|
25
|
+
parseMacHardwarePorts,
|
|
26
|
+
parseMacIfconfig,
|
|
27
|
+
parseWindowsAdapters,
|
|
28
|
+
parseWindowsDefaultRoutes,
|
|
29
|
+
pickLanEndpointModel,
|
|
30
|
+
prefixFromEntry,
|
|
31
|
+
resolveScanSubnets,
|
|
32
|
+
saveLanState,
|
|
33
|
+
scanLan,
|
|
34
|
+
usableHostCount,
|
|
35
|
+
} from '../lanDiscovery.js';
|
|
36
|
+
import { getSettingsHtml } from '../../web-chat/ui/settingsBuilder.js';
|
|
37
|
+
import { COMMAND_NAMES } from '../commandRegistry.js';
|
|
38
|
+
|
|
39
|
+
test('netmask helpers convert between masks, prefixes, and network CIDRs', () => {
|
|
40
|
+
assert.equal(netmaskToPrefix('255.255.255.0'), 24);
|
|
41
|
+
assert.equal(netmaskToPrefix('255.255.240.0'), 20);
|
|
42
|
+
assert.equal(netmaskToPrefix('255.0.0.0'), 8);
|
|
43
|
+
assert.equal(netmaskToPrefix('255.0.255.0'), null);
|
|
44
|
+
assert.equal(netmaskToPrefix('bogus'), null);
|
|
45
|
+
assert.equal(netmaskFromPrefix(24), '255.255.255.0');
|
|
46
|
+
assert.equal(netmaskFromPrefix(20), '255.255.240.0');
|
|
47
|
+
assert.equal(networkCidr('192.168.5.77', 20), '192.168.0.0/20');
|
|
48
|
+
assert.equal(networkCidr('10.1.2.3', 8), '10.0.0.0/8');
|
|
49
|
+
assert.equal(usableHostCount(24), 254);
|
|
50
|
+
assert.equal(usableHostCount(31), 2);
|
|
51
|
+
assert.equal(usableHostCount(32), 1);
|
|
52
|
+
assert.equal(prefixFromEntry({ netmask: '255.255.255.0' }), 24);
|
|
53
|
+
assert.equal(prefixFromEntry({ cidr: '10.0.0.5/16' }), 16);
|
|
54
|
+
assert.equal(prefixFromEntry({}), 24);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('getLanCandidateSubnets honors real netmasks instead of forcing /24', () => {
|
|
58
|
+
const subnets = getLanCandidateSubnets({
|
|
59
|
+
eth0: [{ address: '10.20.30.40', netmask: '255.255.0.0', family: 'IPv4', internal: false }],
|
|
60
|
+
wlan0: [{ address: '192.168.7.9', netmask: '255.255.254.0', family: 'IPv4', internal: false }],
|
|
61
|
+
legacy: [{ address: '192.168.9.9', family: 'IPv4', internal: false }],
|
|
62
|
+
lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }],
|
|
63
|
+
});
|
|
64
|
+
const byCidr = new Map(subnets.map(s => [s.cidr, s]));
|
|
65
|
+
assert.equal(byCidr.get('10.20.0.0/16')?.prefix, 16);
|
|
66
|
+
assert.equal(byCidr.get('10.20.0.0/16')?.hostCount, 65534);
|
|
67
|
+
assert.equal(byCidr.get('192.168.6.0/23')?.prefix, 23);
|
|
68
|
+
assert.equal(byCidr.get('192.168.9.0/24')?.prefix, 24);
|
|
69
|
+
assert.equal(subnets.length, 3);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('expandCidrToHosts honors the prefix and refuses absurd ranges', () => {
|
|
73
|
+
assert.deepEqual(expandCidrToHosts('10.0.0.0/30'), ['10.0.0.1', '10.0.0.2']);
|
|
74
|
+
assert.deepEqual(expandCidrToHosts('10.0.0.4/31'), ['10.0.0.4', '10.0.0.5']);
|
|
75
|
+
assert.equal(expandCidrToHosts('192.168.5.0/23').length, 510);
|
|
76
|
+
assert.throws(() => expandCidrToHosts('10.0.0.0/8'), /cap 65,536/);
|
|
77
|
+
assert.throws(() => expandCidrToHosts('not-a-cidr'), /Invalid CIDR/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('inferInterfaceType classifies common adapter names', () => {
|
|
81
|
+
assert.equal(inferInterfaceType('Wi-Fi', ''), 'wifi');
|
|
82
|
+
assert.equal(inferInterfaceType('wlp2s0', ''), 'wifi');
|
|
83
|
+
assert.equal(inferInterfaceType('Ethernet', ''), 'ethernet');
|
|
84
|
+
assert.equal(inferInterfaceType('eno1', ''), 'ethernet');
|
|
85
|
+
assert.equal(inferInterfaceType('docker0', ''), 'virtual');
|
|
86
|
+
assert.equal(inferInterfaceType('ZeroTier One [abc]', ''), 'virtual');
|
|
87
|
+
assert.equal(inferInterfaceType('vEthernet (Default Switch)', ''), 'virtual');
|
|
88
|
+
assert.equal(inferInterfaceType('utun3', ''), 'virtual');
|
|
89
|
+
assert.equal(inferInterfaceType('lo', ''), 'loopback');
|
|
90
|
+
assert.equal(inferInterfaceType('lo', ''), 'loopback');
|
|
91
|
+
assert.equal(inferInterfaceType('lo0', ''), 'loopback');
|
|
92
|
+
assert.equal(inferInterfaceType('en0', ''), 'unknown');
|
|
93
|
+
assert.equal(inferInterfaceType('Local Area Connection* 9', ''), 'virtual');
|
|
94
|
+
assert.equal(inferInterfaceType('local area network', ''), 'unknown');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('Windows adapter and route parsers handle PowerShell JSON shapes', () => {
|
|
98
|
+
const adapters = parseWindowsAdapters(JSON.stringify([
|
|
99
|
+
{ Name: 'Wi-Fi', InterfaceDescription: 'Intel Wi-Fi 6', Status: 'Up', LinkSpeed: '866.7 Mbps', MediaType: '802.11', InterfaceIndex: 7 },
|
|
100
|
+
{ Name: 'Ethernet', Status: 'Disconnected', MediaType: '802.3', InterfaceIndex: 3 },
|
|
101
|
+
]));
|
|
102
|
+
assert.equal(adapters.length, 2);
|
|
103
|
+
assert.equal(adapters[0].status, 'up');
|
|
104
|
+
assert.equal(adapters[1].status, 'down');
|
|
105
|
+
|
|
106
|
+
const single = parseWindowsAdapters(JSON.stringify({ Name: 'Wi-Fi', Status: 'Up', InterfaceIndex: 7 }));
|
|
107
|
+
assert.equal(single.length, 1);
|
|
108
|
+
|
|
109
|
+
const routes = parseWindowsDefaultRoutes(JSON.stringify([
|
|
110
|
+
{ InterfaceIndex: 7, NextHop: '192.168.1.1', RouteMetric: 25 },
|
|
111
|
+
{ InterfaceIndex: 3, NextHop: '10.0.0.1', RouteMetric: 5 },
|
|
112
|
+
]));
|
|
113
|
+
assert.equal(routes.length, 2);
|
|
114
|
+
assert.equal(parseWindowsAdapters('not json').length, 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('Linux ip parsers extract operstate and default gateways', () => {
|
|
118
|
+
const links = parseLinuxIpAddr(JSON.stringify([
|
|
119
|
+
{ ifname: 'wlp2s0', operstate: 'UP', address: 'aa:bb:cc:dd:ee:ff', mtu: 1500 },
|
|
120
|
+
{ ifname: 'eth0', operstate: 'DOWN' },
|
|
121
|
+
]));
|
|
122
|
+
assert.equal(links[0].status, 'up');
|
|
123
|
+
assert.equal(links[1].status, 'down');
|
|
124
|
+
|
|
125
|
+
const routes = parseLinuxDefaultRoutes(JSON.stringify([
|
|
126
|
+
{ gateway: '192.168.1.1', dev: 'wlp2s0', metric: 600 },
|
|
127
|
+
{ gateway: '10.0.0.1', dev: 'eth0', metric: 100 },
|
|
128
|
+
]));
|
|
129
|
+
assert.equal(routes[0].device, 'wlp2s0');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('macOS parsers read hardware ports, default route, and ifconfig state', () => {
|
|
133
|
+
const ports = parseMacHardwarePorts('Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb\n\nHardware Port: Thunderbolt Ethernet\nDevice: en5\n');
|
|
134
|
+
assert.deepEqual(ports.map(p => p.device), ['en0', 'en5']);
|
|
135
|
+
|
|
136
|
+
const route = parseMacDefaultRoute('route to: default\ngateway: 192.168.1.1\ninterface: en0\n');
|
|
137
|
+
assert.equal(route.gateway, '192.168.1.1');
|
|
138
|
+
assert.equal(route.device, 'en0');
|
|
139
|
+
|
|
140
|
+
const states = parseMacIfconfig('en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500\n\tstatus: active\n\tmedia: autoselect\nlo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n');
|
|
141
|
+
assert.equal(states.get('en0').status, 'up');
|
|
142
|
+
assert.ok(!states.has('lo0') || states.get('lo0').status === 'up');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const fixtureInterfaces = {
|
|
146
|
+
eth0: [{ address: '192.168.10.5', netmask: '255.255.255.0', family: 'IPv4', internal: false, mac: 'aa:bb:cc:dd:ee:01' }],
|
|
147
|
+
wlp2s0: [{ address: '192.168.1.20', netmask: '255.255.255.0', family: 'IPv4', internal: false, mac: 'aa:bb:cc:dd:ee:02' }],
|
|
148
|
+
lo: [{ address: '127.0.0.1', netmask: '255.0.0.0', family: 'IPv4', internal: true, mac: '00:00:00:00:00:00' }],
|
|
149
|
+
'Wi-Fi': [{ address: '192.168.1.50', netmask: '255.255.255.0', family: 'IPv4', internal: false, mac: 'aa:bb:cc:dd:ee:03' }],
|
|
150
|
+
'vEthernet (WSL)': [{ address: '172.30.0.1', netmask: '255.255.240.0', family: 'IPv4', internal: false, mac: 'aa:bb:cc:dd:ee:04' }],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
test('listLanInterfaces builds a selectable baseline without OS commands', async () => {
|
|
154
|
+
const list = await listLanInterfaces({ interfaces: fixtureInterfaces, platform: 'none' });
|
|
155
|
+
assert.equal(list.length, 5);
|
|
156
|
+
assert.deepEqual(list.map(i => i.index), [0, 1, 2, 3, 4]);
|
|
157
|
+
assert.equal(list[0].name, 'eth0');
|
|
158
|
+
assert.equal(list[0].type, 'ethernet');
|
|
159
|
+
assert.equal(list[0].addresses[0].cidr, '192.168.10.0/24');
|
|
160
|
+
assert.equal(list[2].internal, true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('Windows enrichment resolves wifi type, status, and the default gateway', async () => {
|
|
164
|
+
const baseline = await listLanInterfaces({ interfaces: fixtureInterfaces, platform: 'none' });
|
|
165
|
+
const execFn = async (cmd, args) => {
|
|
166
|
+
assert.equal(cmd, 'powershell.exe');
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
adapters: [
|
|
169
|
+
{ Name: 'eth0', Status: 'Up', MediaType: '802.3', InterfaceIndex: 11 },
|
|
170
|
+
{ Name: 'wlp2s0', Status: 'Up', MediaType: '802.11', InterfaceIndex: 22 },
|
|
171
|
+
],
|
|
172
|
+
routes: [{ InterfaceIndex: 22, NextHop: '192.168.1.1', RouteMetric: 25 }],
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
const enriched = await enrichWindowsInterfaces(baseline, execFn);
|
|
176
|
+
const wifi = enriched.find(i => i.name === 'wlp2s0');
|
|
177
|
+
assert.equal(wifi.type, 'wifi');
|
|
178
|
+
assert.equal(wifi.status, 'up');
|
|
179
|
+
assert.equal(wifi.gateway, '192.168.1.1');
|
|
180
|
+
assert.equal(wifi.isDefaultRoute, true);
|
|
181
|
+
assert.equal(enriched.find(i => i.name === 'eth0').isDefaultRoute, false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('Linux enrichment marks operstate and default route device', async () => {
|
|
185
|
+
const baseline = await listLanInterfaces({ interfaces: fixtureInterfaces, platform: 'none' });
|
|
186
|
+
const execFn = async (cmd, args) => {
|
|
187
|
+
if (cmd === 'ip' && args[1] === 'addr') {
|
|
188
|
+
return JSON.stringify([
|
|
189
|
+
{ ifname: 'wlp2s0', operstate: 'UP' },
|
|
190
|
+
{ ifname: 'eth0', operstate: 'DOWN' },
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
return JSON.stringify([{ gateway: '192.168.1.1', dev: 'wlp2s0', metric: 600 }]);
|
|
194
|
+
};
|
|
195
|
+
const enriched = await enrichLinuxInterfaces(baseline, execFn);
|
|
196
|
+
assert.equal(enriched.find(i => i.name === 'wlp2s0').status, 'up');
|
|
197
|
+
assert.equal(enriched.find(i => i.name === 'eth0').status, 'down');
|
|
198
|
+
assert.equal(enriched.find(i => i.name === 'wlp2s0').isDefaultRoute, true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('macOS enrichment maps friendly port names and active state', async () => {
|
|
202
|
+
const macFixture = {
|
|
203
|
+
en0: [{ address: '192.168.1.30', netmask: '255.255.255.0', family: 'IPv4', internal: false, mac: 'aa:bb:cc:dd:ee:ff' }],
|
|
204
|
+
};
|
|
205
|
+
const baseline = await listLanInterfaces({ interfaces: macFixture, platform: 'none' });
|
|
206
|
+
const execFn = async (cmd) => {
|
|
207
|
+
if (cmd === 'ifconfig') return 'en0: flags=8863<UP,BROADCAST,SMART,RUNNING> mtu 1500\n\tstatus: active\n\tmedia: autoselect (1000baseT <full-duplex>)\n';
|
|
208
|
+
if (cmd === 'route') return 'route to: default\ngateway: 192.168.1.1\ninterface: en0\n';
|
|
209
|
+
return 'Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb\n';
|
|
210
|
+
};
|
|
211
|
+
const enriched = await enrichMacInterfaces(baseline, execFn);
|
|
212
|
+
assert.equal(enriched[0].displayName, 'Wi-Fi (en0)');
|
|
213
|
+
assert.equal(enriched[0].gateway, '192.168.1.1');
|
|
214
|
+
assert.equal(enriched[0].isDefaultRoute, true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('resolveScanSubnets selects subnets by index, name, or direct CIDR', async () => {
|
|
218
|
+
const opts = { interfaces: fixtureInterfaces, platform: 'none' };
|
|
219
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: '1' }), ['192.168.1.0/24']);
|
|
220
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: 'eth0' }), ['192.168.10.0/24']);
|
|
221
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: 'ETH0' }), ['192.168.10.0/24']);
|
|
222
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: '10.9.0.0/16' }), ['10.9.0.0/16']);
|
|
223
|
+
assert.equal(await resolveScanSubnets({ ...opts, iface: null }), null);
|
|
224
|
+
assert.equal(await resolveScanSubnets({ ...opts, iface: 'auto' }), null);
|
|
225
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: 'eth0', subnets: ['10.1.0.0/24'] }), ['10.1.0.0/24']);
|
|
226
|
+
await assert.rejects(() => resolveScanSubnets({ ...opts, iface: 'nope' }), /Unknown LAN interface/);
|
|
227
|
+
await assert.rejects(() => resolveScanSubnets({ ...opts, iface: 'lo' }), /internal\/loopback/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const WSL_IP_ADDR = `1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
|
231
|
+
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
|
232
|
+
inet 127.0.0.1/8 scope host lo
|
|
233
|
+
valid_lft forever preferred_lft forever
|
|
234
|
+
inet 10.255.255.254/32 brd 10.255.255.254 scope global lo
|
|
235
|
+
valid_lft forever preferred_lft forever
|
|
236
|
+
inet6 ::1/128 scope host proto kernel_lo
|
|
237
|
+
valid_lft forever preferred_lft forever
|
|
238
|
+
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
|
|
239
|
+
link/ether 00:15:5d:14:58:0a brd ff:ff:ff:ff:ff:ff
|
|
240
|
+
inet 172.30.12.205/20 brd 172.30.15.255 scope global eth0
|
|
241
|
+
valid_lft forever preferred_lft forever
|
|
242
|
+
inet6 fe80::215:5dff:fe14:580a/64 scope link proto kernel_ll
|
|
243
|
+
valid_lft forever preferred_lft forever
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
test('Linux plain-text ip addr fallback parses real WSL output', () => {
|
|
247
|
+
const links = parseLinuxIpAddrText(WSL_IP_ADDR);
|
|
248
|
+
assert.equal(links.length, 2);
|
|
249
|
+
assert.equal(links[0].name, 'lo');
|
|
250
|
+
assert.equal(links[0].status, 'up');
|
|
251
|
+
assert.equal(links[1].name, 'eth0');
|
|
252
|
+
assert.equal(links[1].status, 'up');
|
|
253
|
+
assert.equal(links[1].mac, '00:15:5d:14:58:0a');
|
|
254
|
+
assert.equal(links[1].mtu, 1500);
|
|
255
|
+
assert.deepEqual(parseLinuxIpAddrText(''), []);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('Linux UNKNOWN operstate with UP flags still counts as up', () => {
|
|
259
|
+
const fromJson = parseLinuxIpAddr(JSON.stringify([
|
|
260
|
+
{ ifname: 'lo', operstate: 'UNKNOWN', flags: ['LOOPBACK', 'UP', 'LOWER_UP'] },
|
|
261
|
+
{ ifname: 'eth0', operstate: 'UNKNOWN', flags: ['BROADCAST', 'MULTICAST'] },
|
|
262
|
+
]));
|
|
263
|
+
assert.equal(fromJson[0].status, 'up');
|
|
264
|
+
assert.equal(fromJson[1].status, 'unknown');
|
|
265
|
+
|
|
266
|
+
const fromText = parseLinuxIpAddrText('3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 state DOWN\n inet 172.17.0.1/16 scope global docker0\n');
|
|
267
|
+
assert.equal(fromText[0].status, 'down');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('Linux enrichment falls back to plain ip addr when -j is unavailable', async () => {
|
|
271
|
+
const baseline = await listLanInterfaces({
|
|
272
|
+
interfaces: {
|
|
273
|
+
eth0: [{ address: '172.30.12.205', netmask: '255.255.240.0', family: 'IPv4', internal: false }],
|
|
274
|
+
},
|
|
275
|
+
platform: 'none',
|
|
276
|
+
});
|
|
277
|
+
const execFn = async (cmd, args) => {
|
|
278
|
+
if (args[0] === '-j') throw new Error('ip: Option "-j" is unknown');
|
|
279
|
+
if (args[0] === 'addr') return WSL_IP_ADDR;
|
|
280
|
+
return '[]';
|
|
281
|
+
};
|
|
282
|
+
const enriched = await enrichLinuxInterfaces(baseline, execFn);
|
|
283
|
+
assert.equal(enriched[0].status, 'up');
|
|
284
|
+
assert.equal(enriched[0].mac, '00:15:5d:14:58:0a');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('Windows enrichment lists disconnected adapters missing from Node', async () => {
|
|
288
|
+
const baseline = await listLanInterfaces({
|
|
289
|
+
interfaces: {
|
|
290
|
+
'Wi-Fi': [{ address: '192.168.1.114', netmask: '255.255.255.0', family: 'IPv4', internal: false }],
|
|
291
|
+
},
|
|
292
|
+
platform: 'none',
|
|
293
|
+
});
|
|
294
|
+
const execFn = async () => JSON.stringify({
|
|
295
|
+
adapters: [
|
|
296
|
+
{ Name: 'Wi-Fi', InterfaceDescription: 'Intel Wi-Fi', Status: 'Up', LinkSpeed: '866 Mbps', MediaType: '802.11', InterfaceIndex: 7 },
|
|
297
|
+
{ Name: 'Local Area Connection* 9', InterfaceDescription: 'Microsoft Wi-Fi Direct', Status: 'Disconnected', MediaType: '802.11', InterfaceIndex: 9 },
|
|
298
|
+
{ Name: 'Bluetooth Network Connection', InterfaceDescription: 'Bluetooth', Status: 'Disconnected', MediaType: '802.3', InterfaceIndex: 12 },
|
|
299
|
+
],
|
|
300
|
+
routes: [{ InterfaceIndex: 7, NextHop: '192.168.1.1', RouteMetric: 25 }],
|
|
301
|
+
});
|
|
302
|
+
const enriched = await enrichWindowsInterfaces(baseline, execFn);
|
|
303
|
+
assert.equal(enriched.length, 3);
|
|
304
|
+
const missing = enriched.find(i => i.name === 'Local Area Connection* 9');
|
|
305
|
+
assert.ok(missing);
|
|
306
|
+
assert.equal(missing.status, 'down');
|
|
307
|
+
assert.deepEqual(missing.addresses, []);
|
|
308
|
+
assert.equal(missing.type, 'virtual');
|
|
309
|
+
const bt = enriched.find(i => i.name === 'Bluetooth Network Connection');
|
|
310
|
+
assert.equal(bt.status, 'down');
|
|
311
|
+
assert.equal(enriched.find(i => i.name === 'Wi-Fi').isDefaultRoute, true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('macOS associated Wi-Fi status counts as up', () => {
|
|
315
|
+
const states = parseMacIfconfig('en0: flags=8863<UP,BROADCAST,SMART,RUNNING> mtu 1500\n\tstatus: associated\n\tmedia: autoselect\n');
|
|
316
|
+
assert.equal(states.get('en0').status, 'up');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('classifyScanToken separates network shapes from adapter names', () => {
|
|
320
|
+
assert.equal(classifyScanToken('192.168.1.0/24'), 'subnet');
|
|
321
|
+
assert.equal(classifyScanToken('10.0.0.1-10.0.0.30'), 'subnet');
|
|
322
|
+
assert.equal(classifyScanToken('192.168.1.50'), 'subnet');
|
|
323
|
+
assert.equal(classifyScanToken('999.1.1.1'), 'subnet');
|
|
324
|
+
assert.equal(classifyScanToken('Wi-Fi'), 'name');
|
|
325
|
+
assert.equal(classifyScanToken('eth0'), 'name');
|
|
326
|
+
assert.equal(classifyScanToken('vEthernet (WSL)'), 'name');
|
|
327
|
+
assert.equal(classifyScanToken(''), 'empty');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('hyphenated adapter names resolve as interfaces, not IP ranges', async () => {
|
|
331
|
+
const opts = { interfaces: fixtureInterfaces, platform: 'none' };
|
|
332
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: 'Wi-Fi' }), ['192.168.1.0/24']);
|
|
333
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: 'wi-fi' }), ['192.168.1.0/24']);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('resolveScanSubnets supports unique substrings and rejects ambiguity', async () => {
|
|
337
|
+
const opts = { interfaces: fixtureInterfaces, platform: 'none' };
|
|
338
|
+
assert.deepEqual(await resolveScanSubnets({ ...opts, iface: 'vEth' }), ['172.30.0.0/20']);
|
|
339
|
+
await assert.rejects(() => resolveScanSubnets({ ...opts, iface: 'e' }), /Ambiguous interface/);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('resolveScanSubnets refuses an interface whose subnet exceeds the cap', async () => {
|
|
343
|
+
const opts = { interfaces: fixtureInterfaces, platform: 'none', maxHosts: 100 };
|
|
344
|
+
await assert.rejects(
|
|
345
|
+
() => resolveScanSubnets({ ...opts, iface: 'vEthernet (WSL)' }),
|
|
346
|
+
/over the 100-host cap.*slice instead/s
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('scanLan auto sweep packs small LANs first and reports skipped giants', async () => {
|
|
351
|
+
clearLanScanCache();
|
|
352
|
+
const bigFixtures = {
|
|
353
|
+
office: [{ address: '192.168.10.5', netmask: '255.255.255.0', family: 'IPv4', internal: false }],
|
|
354
|
+
lab: [{ address: '192.168.20.5', netmask: '255.255.254.0', family: 'IPv4', internal: false }],
|
|
355
|
+
hyperv: [{ address: '172.30.0.1', netmask: '255.255.0.0', family: 'IPv4', internal: false }],
|
|
356
|
+
};
|
|
357
|
+
const seen = new Set();
|
|
358
|
+
const result = await scanLan({
|
|
359
|
+
interfaces: bigFixtures,
|
|
360
|
+
platform: 'none',
|
|
361
|
+
ports: [1234],
|
|
362
|
+
maxHosts: 800,
|
|
363
|
+
force: true,
|
|
364
|
+
connector: async (host) => { seen.add(host.split('.')[2]); return false; },
|
|
365
|
+
prober: async () => ({ reachable: false }),
|
|
366
|
+
});
|
|
367
|
+
assert.deepEqual(result.subnets, ['192.168.10.0/24', '192.168.20.0/23']);
|
|
368
|
+
assert.equal(result.skippedSubnets.length, 1);
|
|
369
|
+
assert.equal(result.skippedSubnets[0].cidr, '172.30.0.0/16');
|
|
370
|
+
assert.equal(result.skippedSubnets[0].reason, 'over-budget');
|
|
371
|
+
assert.ok(!seen.has('30'));
|
|
372
|
+
clearLanScanCache();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('scanLan honors iface selection end to end', async () => {
|
|
376
|
+
clearLanScanCache();
|
|
377
|
+
const seen = [];
|
|
378
|
+
const result = await scanLan({
|
|
379
|
+
iface: 'wlp2s0',
|
|
380
|
+
interfaces: fixtureInterfaces,
|
|
381
|
+
platform: 'none',
|
|
382
|
+
ports: [1234],
|
|
383
|
+
force: true,
|
|
384
|
+
connector: async (host, port) => {
|
|
385
|
+
seen.push(`${host}:${port}`);
|
|
386
|
+
return host === '192.168.1.50' && port === 1234;
|
|
387
|
+
},
|
|
388
|
+
prober: async ({ host, port }) => ({
|
|
389
|
+
host, port, url: `http://${host}:${port}/v1`, reachable: true,
|
|
390
|
+
serverType: 'lmstudio', serverName: 'LM Studio', models: ['m'], modelCount: 1,
|
|
391
|
+
authRequired: false, latencyMs: 5,
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
assert.deepEqual(result.subnets, ['192.168.1.0/24']);
|
|
395
|
+
assert.ok(seen.length > 200 && seen.length < 300);
|
|
396
|
+
assert.ok(seen.every(s => s === '127.0.0.1:1234' || s.startsWith('192.168.1.')));
|
|
397
|
+
assert.equal(result.endpoints.length, 1);
|
|
398
|
+
clearLanScanCache();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('settings UI offers an interface picker for the LAN scan', () => {
|
|
402
|
+
const html = getSettingsHtml();
|
|
403
|
+
assert.match(html, /id="lan-scan-iface"/);
|
|
404
|
+
assert.match(html, /function loadLanInterfaces\(/);
|
|
405
|
+
assert.match(html, /lblLanIface/);
|
|
406
|
+
assert.match(html, /iface=' \+ encodeURIComponent\(iface\)/);
|
|
407
|
+
assert.match(html, /kiku_lan_iface/);
|
|
408
|
+
assert.match(html, /lanScannedScope/);
|
|
409
|
+
assert.match(html, /lanSkippedScope/);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('parseLanScanArgs splits targets, flags, and interface selection', () => {
|
|
413
|
+
assert.deepEqual(parseLanScanArgs([]), { subnets: null, iface: null, ports: null, force: false, error: null });
|
|
414
|
+
assert.deepEqual(parseLanScanArgs(['192.168.1.0/24']).subnets, ['192.168.1.0/24']);
|
|
415
|
+
assert.deepEqual(
|
|
416
|
+
parseLanScanArgs(['10.0.0.1-10.0.0.30', '--ports', '1234,8000']).ports,
|
|
417
|
+
[1234, 8000]
|
|
418
|
+
);
|
|
419
|
+
assert.deepEqual(parseLanScanArgs(['--ports=11434', '10.0.0.5']).subnets, ['10.0.0.5']);
|
|
420
|
+
assert.equal(parseLanScanArgs(['scan', '--iface', 'Wi-Fi']).iface, 'Wi-Fi');
|
|
421
|
+
assert.equal(parseLanScanArgs(['--iface=2']).iface, '2');
|
|
422
|
+
assert.equal(parseLanScanArgs(['--interface', 'eth0']).iface, 'eth0');
|
|
423
|
+
assert.equal(parseLanScanArgs(['--force', '192.168.1.0/24']).force, true);
|
|
424
|
+
assert.equal(parseLanScanArgs(['-f']).force, true);
|
|
425
|
+
assert.equal(parseLanScanArgs(['--ports']).error, 'ports');
|
|
426
|
+
assert.equal(parseLanScanArgs(['--ports', 'abc']).error, 'ports');
|
|
427
|
+
assert.equal(parseLanScanArgs(['--iface']).error, 'iface');
|
|
428
|
+
const mixed = parseLanScanArgs(['--iface', '1', '--ports', '1234', '192.168.2.0/24']);
|
|
429
|
+
assert.equal(mixed.iface, '1');
|
|
430
|
+
assert.deepEqual(mixed.ports, [1234]);
|
|
431
|
+
assert.deepEqual(mixed.subnets, ['192.168.2.0/24']);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('describeLanInterface renders a one-line selectable summary', () => {
|
|
435
|
+
const line = describeLanInterface({
|
|
436
|
+
index: 3, name: 'Wi-Fi', displayName: 'Wi-Fi', type: 'wifi', status: 'up',
|
|
437
|
+
gateway: '192.168.1.1', isDefaultRoute: true, internal: false,
|
|
438
|
+
addresses: [{ address: '192.168.1.20', cidr: '192.168.1.0/24' }],
|
|
439
|
+
});
|
|
440
|
+
assert.match(line, /\[3\] Wi-Fi/);
|
|
441
|
+
assert.match(line, /192\.168\.1\.0\/24/);
|
|
442
|
+
assert.match(line, /gw=192\.168\.1\.1/);
|
|
443
|
+
assert.match(line, /default/);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('the /lan command is registered with interface selection usage', async () => {
|
|
447
|
+
assert.ok(COMMAND_NAMES.includes('/lan'));
|
|
448
|
+
const { getCommandDetails } = await import('../commandRegistry.js');
|
|
449
|
+
const details = getCommandDetails('/lan');
|
|
450
|
+
assert.match(details?.usage || '', /--iface/);
|
|
451
|
+
assert.match(details?.usage || '', /ifs/);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('lan state persists the last scan and sticky interface across restarts', async () => {
|
|
455
|
+
const { mkdtemp, rm } = await import('node:fs/promises');
|
|
456
|
+
const { tmpdir } = await import('node:os');
|
|
457
|
+
const { join } = await import('node:path');
|
|
458
|
+
const dir = await mkdtemp(join(tmpdir(), 'kiku-lan-'));
|
|
459
|
+
try {
|
|
460
|
+
const empty = await loadLanState({ dataDir: dir });
|
|
461
|
+
assert.deepEqual(empty, { lastIface: null, lastScan: null });
|
|
462
|
+
|
|
463
|
+
const saved = await saveLanState({
|
|
464
|
+
lastIface: 'Wi-Fi',
|
|
465
|
+
lastScan: { savedAt: '2026-09-15T00:00:00.000Z', subnets: ['192.168.1.0/24'], endpoints: [{ host: '192.168.1.50', port: 1234 }] },
|
|
466
|
+
}, { dataDir: dir });
|
|
467
|
+
assert.equal(saved.lastIface, 'Wi-Fi');
|
|
468
|
+
|
|
469
|
+
const reloaded = await loadLanState({ dataDir: dir });
|
|
470
|
+
assert.equal(reloaded.lastIface, 'Wi-Fi');
|
|
471
|
+
assert.equal(reloaded.lastScan.endpoints.length, 1);
|
|
472
|
+
|
|
473
|
+
const merged = await saveLanState({ lastIface: 'eth0' }, { dataDir: dir });
|
|
474
|
+
assert.equal(merged.lastIface, 'eth0');
|
|
475
|
+
assert.equal(merged.lastScan.endpoints.length, 1);
|
|
476
|
+
} finally {
|
|
477
|
+
await rm(dir, { recursive: true, force: true });
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('describeLanScanAge buckets scan freshness for humans', () => {
|
|
482
|
+
const now = new Date('2026-09-15T12:00:00.000Z').getTime();
|
|
483
|
+
assert.equal(describeLanScanAge(new Date(now - 30 * 1000).toISOString(), now), 'just now');
|
|
484
|
+
assert.equal(describeLanScanAge(new Date(now - 5 * 60000).toISOString(), now), '5m ago');
|
|
485
|
+
assert.equal(describeLanScanAge(new Date(now - 3 * 3600000).toISOString(), now), '3h ago');
|
|
486
|
+
assert.equal(describeLanScanAge(new Date(now - 2 * 86400000).toISOString(), now), '2d ago');
|
|
487
|
+
assert.equal(describeLanScanAge(null, now), 'unknown age');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('pickLanEndpointModel selects by index, name, or falls back to first', () => {
|
|
491
|
+
const endpoint = { host: 'h', port: 1, models: ['google/gemma-4-e4b', 'Qwen/Qwen2.5-7B', 'llama3'] };
|
|
492
|
+
assert.deepEqual(pickLanEndpointModel(endpoint, null), { model: 'google/gemma-4-e4b', matched: true, via: 'first' });
|
|
493
|
+
assert.equal(pickLanEndpointModel(endpoint, '2').model, 'Qwen/Qwen2.5-7B');
|
|
494
|
+
assert.equal(pickLanEndpointModel(endpoint, 'llama3').via, 'exact');
|
|
495
|
+
assert.equal(pickLanEndpointModel(endpoint, 'qwen').via, 'substring');
|
|
496
|
+
assert.equal(pickLanEndpointModel({ models: [] }, null).matched, false);
|
|
497
|
+
const miss = pickLanEndpointModel(endpoint, 'nope');
|
|
498
|
+
assert.equal(miss.matched, false);
|
|
499
|
+
const ambiguous = pickLanEndpointModel({ models: ['a-1', 'a-2'] }, 'a-');
|
|
500
|
+
assert.equal(ambiguous.matched, false);
|
|
501
|
+
assert.equal(ambiguous.ambiguous.length, 2);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('scan panel exposes ports input with persisted value', () => {
|
|
505
|
+
const html = getSettingsHtml();
|
|
506
|
+
assert.match(html, /id="lan-scan-ports"/);
|
|
507
|
+
assert.match(html, /kiku_lan_ports/);
|
|
508
|
+
assert.match(html, /lblLanPorts/);
|
|
509
|
+
assert.match(html, /lanSkippedScope/);
|
|
510
|
+
});
|