dsh-ris-yds 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,194 @@
1
+ // Tests for the borrowed proxy engine: rule generation, discovery order, and the
2
+ // fallback decision when dsh-ris-ide is not installed.
3
+ import assert from 'node:assert/strict'
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import test from 'node:test'
8
+
9
+ import {
10
+ YDS_RULE_SOURCE, applyGitProxy, ensureBorrowedProxy, gitHostOf, ideProxyCandidates,
11
+ isIpv4Literal, isPrivateIpv4, loadIdeProxy, privateSegmentsOf, proxyRulesFromTargets,
12
+ shadowTargetsFromTargets,
13
+ toProcessPattern, yoncodeRules,
14
+ } from '../lib/ide-proxy.js'
15
+
16
+ test('only a dotted-quad IPv4 literal counts as bindable', () => {
17
+ assert.equal(isIpv4Literal('172.20.235.80'), true)
18
+ assert.equal(isIpv4Literal('172.20.235.*'), false, 'a wildcard cannot be pre-bound')
19
+ assert.equal(isIpv4Literal('srmdev.hanslaser.com'), false)
20
+ assert.equal(isIpv4Literal(''), false)
21
+ })
22
+
23
+ test('rules name real programs: java for the internal segment, git for its remote', () => {
24
+ const rules = proxyRulesFromTargets([
25
+ { host: '172.20.235.80', port: 1521 },
26
+ { host: '172.20.235.81', port: 6890, isRedis: true },
27
+ { host: '172.20.235.81', port: 6880 },
28
+ // duplicates collapse, garbage is dropped
29
+ { host: '172.20.235.81', port: 6880 },
30
+ { host: 'srmdev.hanslaser.com', port: 443 },
31
+ { host: '', port: 1 },
32
+ { host: '10.0.0.1', port: 0 },
33
+ { host: '10.0.0.1', port: 70000 },
34
+ ], '/w', { gitHost: 'gitlab.hanslaser.com', java: true })
35
+ // Endpoints are NOT rows: two rules, both naming a real process.
36
+ // Every axis is explicit: "any port" is spelled `*`, because a blank port no
37
+ // longer makes a rule apply (the dialog rejects half-written rows).
38
+ assert.deepEqual(rules.map((rule) => `${rule.program}|${rule.hosts}|${rule.ports}`), [
39
+ 'java|172.20.235.*|*',
40
+ 'git|gitlab.hanslaser.com|*',
41
+ ])
42
+ assert.equal(rules.every((rule) => rule.source === YDS_RULE_SOURCE), true)
43
+ assert.equal(rules.every((rule) => rule.program !== 'jdbc'), true, 'jdbc is not a process name')
44
+ assert.equal(rules.every((rule) => rule.shadow === false), true, 'the table holds intent only; no mapping rows')
45
+ assert.equal(rules[0].id, 'yds-java-172-20-235', 'one rule per internal segment, stable id')
46
+ assert.equal(rules[1].id, 'yds-git')
47
+ // Without a git URL there is no git rule to write.
48
+ const noGit = proxyRulesFromTargets([{ host: '172.20.235.80', port: 1521 }], '/w', { gitHost: '', java: true })
49
+ assert.equal(noGit.some((rule) => rule.program === 'git'), false)
50
+ })
51
+
52
+ test('the no-root fallback is a separate listener list, not rules', () => {
53
+ const targets = [
54
+ { host: '172.20.235.80', port: 1521 },
55
+ { host: '172.20.235.81', port: 6890 },
56
+ { host: '172.20.235.81', port: 6890 },
57
+ { host: 'srmdev.hanslaser.com', port: 443 },
58
+ { host: '10.0.0.1', port: 70000 },
59
+ ]
60
+ assert.deepEqual(shadowTargetsFromTargets(targets), [
61
+ { host: '172.20.235.80', port: 1521 },
62
+ { host: '172.20.235.81', port: 6890 },
63
+ ], 'one listener per literal IPv4 endpoint, duplicates collapsed, hostnames skipped')
64
+ assert.deepEqual(shadowTargetsFromTargets([null, undefined, {}]), [])
65
+ })
66
+
67
+
68
+ test('the git host comes from the clone URL, never from a guess', () => {
69
+ assert.equal(gitHostOf({ git: { url: 'https://gitlab.hanslaser.com/hans-dev/hans-asset/yonasset.git' } }), 'gitlab.hanslaser.com')
70
+ assert.equal(gitHostOf({ git: { url: 'not a url' } }), '')
71
+ assert.equal(gitHostOf({ git: null }), '')
72
+ })
73
+
74
+ test('candidate locations cover the profile link, a workspace install and a sibling checkout', () => {
75
+ const list = ideProxyCandidates({ workspace: '/w' })
76
+ assert.ok(list.some((path) => path === '/w/node_modules/dsh-ris-ide/lib/proxy.js'))
77
+ assert.ok(list.some((path) => path === '/w/work/dsh-ris-ide/lib/proxy.js'))
78
+ // join() normalizes, so the sibling checkout resolves next to the workspace.
79
+ assert.ok(list.some((path) => path === '/dsh-ris-ide/lib/proxy.js'))
80
+ assert.ok(list.some((path) => path.endsWith('.dsh/profiles/web/node_modules/dsh-ris-ide/lib/proxy.js')))
81
+ })
82
+
83
+ test('a machine without dsh-ris-ide reports unavailable instead of throwing', async () => {
84
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
85
+ const previous = process.env.YDS_IDE_PROXY
86
+ try {
87
+ // The documented off switch: no search at all, so the machine's real install
88
+ // cannot leak into the test.
89
+ process.env.YDS_IDE_PROXY = 'none'
90
+ const loaded = await loadIdeProxy({ workspace: dir })
91
+ assert.equal(loaded.available, false)
92
+ assert.match(loaded.error, /YDS_IDE_PROXY=none/)
93
+ } finally {
94
+ if (previous === undefined) delete process.env.YDS_IDE_PROXY
95
+ else process.env.YDS_IDE_PROXY = previous
96
+ rmSync(dir, { recursive: true, force: true })
97
+ }
98
+ })
99
+
100
+ test('direct mode never touches the proxy engine', async () => {
101
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
102
+ try {
103
+ const result = await ensureBorrowedProxy({ workspace: dir, socks: null, proxyEngine: 'auto' }, [{ host: '10.0.0.1', port: 1521 }])
104
+ assert.equal(result.mode, 'none')
105
+ // …and the git helper refuses politely instead of writing a broken entry.
106
+ const git = await applyGitProxy({ workspace: dir, socks: null }, { enabled: true })
107
+ assert.equal(git.ok, false)
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true })
110
+ }
111
+ })
112
+
113
+ test('proxy.engine=self disables the borrow even when the engine is present', async () => {
114
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
115
+ try {
116
+ const result = await ensureBorrowedProxy(
117
+ { workspace: dir, socks: { host: '10.3.91.121', port: 1080 }, proxyEngine: 'self' },
118
+ [{ host: '172.20.235.80', port: 1521 }],
119
+ )
120
+ assert.equal(result.mode, 'unavailable')
121
+ assert.match(result.error, /proxy\.engine=self/)
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true })
124
+ }
125
+ })
126
+
127
+ test('a broken engine module is reported, not fatal', async () => {
128
+ const dir = mkdtempSync(join(tmpdir(), 'yds-proxy-'))
129
+ const previous = process.env.YDS_IDE_PROXY
130
+ try {
131
+ mkdirSync(join(dir, 'lib'), { recursive: true })
132
+ writeFileSync(join(dir, 'lib', 'proxy.js'), 'export default {}\n')
133
+ process.env.YDS_IDE_PROXY = join(dir, 'lib', 'proxy.js')
134
+ const loaded = await loadIdeProxy({ workspace: dir })
135
+ assert.equal(loaded.available, false)
136
+ assert.match(loaded.error, /不完整/)
137
+ } finally {
138
+ if (previous === undefined) delete process.env.YDS_IDE_PROXY
139
+ else process.env.YDS_IDE_PROXY = previous
140
+ rmSync(dir, { recursive: true, force: true })
141
+ }
142
+ })
143
+
144
+ test('YonCode own executables get their own labelled rules', async () => {
145
+ const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs')
146
+ const { tmpdir } = await import('node:os')
147
+ const home = mkdtempSync(join(tmpdir(), 'yds-yoncode-'))
148
+ mkdirSync(join(home, 'devkit', 'jdk21.0.7', 'bin'), { recursive: true })
149
+ writeFileSync(join(home, 'devkit', 'jdk21.0.7', 'bin', 'java.exe'), '')
150
+ const gitDir = mkdtempSync(join(tmpdir(), 'yds-git-'))
151
+ const gitExe = join(gitDir, 'git.exe')
152
+ writeFileSync(gitExe, '')
153
+ // A POSIX home keeps the pattern POSIX; /mnt/<drive>/ paths are rewritten to a
154
+ // drive letter, which is what ProxyBridge on Windows compares against.
155
+ const rules = yoncodeRules({ workspace: '/w' }, {
156
+ homes: [home],
157
+ gitPaths: [gitExe],
158
+ gitHost: 'gitlab.example.com',
159
+ hosts: ['172.20.235.*', '172.20.63.*'],
160
+ })
161
+ assert.deepEqual(rules.map((rule) => rule.id), ['yds-yoncode-java', 'yds-yoncode-git'])
162
+ assert.deepEqual(rules.map((rule) => rule.label), ['YonCode java', 'YonCode git'])
163
+ // `jdk*\bin\*.exe`: scoped to the JDK trees (devkit also holds maven/node) and
164
+ // covering java.exe/javaw.exe, so a JDK upgrade does not invalidate the rule.
165
+ assert.equal(rules[0].program, join(home, 'devkit', 'jdk*', 'bin', '*.exe'))
166
+ // The range comes from the endpoints we resolved, never from a hardcoded vendor range.
167
+ assert.equal(rules[0].hosts, '172.20.235.*,172.20.63.*')
168
+ assert.equal(rules[0].ports, '*')
169
+ assert.equal(rules[0].shadow, false)
170
+ assert.equal(rules[1].program, gitExe)
171
+ assert.equal(rules[1].hosts, 'gitlab.example.com')
172
+ assert.equal(rules.every((rule) => rule.source === 'yds'), true)
173
+ // No YonCode install and no git: no rows invented.
174
+ assert.deepEqual(yoncodeRules({}, { homes: [home], gitPaths: [], gitHost: 'x', hosts: ['10.0.0.*'] }).map((r) => r.id), ['yds-yoncode-java'])
175
+ // No resolved endpoint range = no java row invented (a row with an empty host list
176
+ // would match nothing, and a guessed range would redirect the wrong traffic).
177
+ assert.deepEqual(yoncodeRules({}, { homes: [home], gitPaths: [], gitHost: 'x' }), [])
178
+ assert.deepEqual(yoncodeRules({}, { homes: [], gitPaths: [], gitHost: 'x' }), [])
179
+ })
180
+
181
+ test('executable rows inherit the private ranges of the resolved endpoints', () => {
182
+ const segments = privateSegmentsOf(
183
+ [{ host: '172.20.235.80', port: 1521 }, { host: '172.20.235.81', port: 6890 }, { host: 'gitlab.example.com', port: 443 }],
184
+ ['172.20.63.131'],
185
+ )
186
+ assert.deepEqual(segments, ['172.20.235.*', '172.20.63.*'])
187
+ assert.deepEqual(privateSegmentsOf([], []), [])
188
+ })
189
+
190
+ test('a mounted YonCode path becomes a drive-letter pattern', () => {
191
+ assert.equal(toProcessPattern('/mnt/d/Program Files/YonCode/devkit/x/bin/java.exe'), 'D:\\Program Files\\YonCode\\devkit\\x\\bin\\java.exe')
192
+ assert.equal(toProcessPattern('C:\\Program Files\\YonCode\\x\\java.exe'), 'C:\\Program Files\\YonCode\\x\\java.exe')
193
+ assert.equal(toProcessPattern('/opt/YonCode/devkit/x/bin/java'), '/opt/YonCode/devkit/x/bin/java')
194
+ })
@@ -0,0 +1,79 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
4
+ import { readFileSync as readFile } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { listWatched, reconcileOnce, registerWorkspace, unregisterWorkspace, watchWorkspace } from '../lib/ide-watch.js'
8
+
9
+ function sandbox() {
10
+ return mkdtempSync(join(tmpdir(), 'yds-watch-'))
11
+ }
12
+
13
+ test('registration survives in the state file and remove takes it out', () => {
14
+ const home = sandbox()
15
+ const ws = sandbox()
16
+ assert.deepEqual(listWatched(home), [])
17
+ const added = registerWorkspace(ws, home)
18
+ assert.equal(added.ok, true)
19
+ assert.deepEqual(added.workspaces, [ws])
20
+ assert.deepEqual(listWatched(home), [ws])
21
+ registerWorkspace(ws, home)
22
+ assert.equal(listWatched(home).length, 1, 'registering twice keeps one entry')
23
+ const removed = unregisterWorkspace(ws, home)
24
+ assert.deepEqual(removed.workspaces, [])
25
+ assert.equal(existsSync(join(home, '.dsh-ris-yds', 'ide-watch.json')), false, 'last removal drops the file')
26
+ rmSync(home, { recursive: true, force: true })
27
+ rmSync(ws, { recursive: true, force: true })
28
+ })
29
+
30
+ test('nothing happens without a config file or without a repair step', async () => {
31
+ const ws = sandbox()
32
+ let calls = 0
33
+ const repair = async () => { calls += 1; return { changed: true } }
34
+ assert.deepEqual(await reconcileOnce(ws, { repair }), { changed: false, reason: 'no-config' })
35
+ mkdirSync(join(ws, '.dsh-ris-ide'), { recursive: true })
36
+ writeFileSync(join(ws, '.dsh-ris-ide', 'config.json'), '{"programs":[]}')
37
+ assert.deepEqual(await reconcileOnce(ws, {}), { changed: false, reason: 'no-repair' })
38
+ assert.equal(calls, 0)
39
+ rmSync(ws, { recursive: true, force: true })
40
+ })
41
+
42
+ test('a restore that writes nothing is reported as a no-op', async () => {
43
+ const ws = sandbox()
44
+ mkdirSync(join(ws, '.dsh-ris-ide'), { recursive: true })
45
+ writeFileSync(join(ws, '.dsh-ris-ide', 'config.json'), '{"programs":[]}')
46
+ const result = await reconcileOnce(ws, { repair: async () => ({ changed: true, reason: 'unchanged' }) })
47
+ assert.deepEqual(result, { changed: false, reason: 'no-op' })
48
+ rmSync(ws, { recursive: true, force: true })
49
+ })
50
+
51
+ test('a rewrite by ris-ide triggers the repair and the row comes back', async () => {
52
+ const ws = sandbox()
53
+ mkdirSync(join(ws, '.dsh-ris-ide'), { recursive: true })
54
+ const configPath = join(ws, '.dsh-ris-ide', 'config.json')
55
+ writeFileSync(configPath, '{"programs":[]}')
56
+ // minimal stand-in for applyIdeConfig: put our row back
57
+ const repair = async () => {
58
+ const doc = JSON.parse(readFileSync(configPath, 'utf8'))
59
+ doc.programs = [{ id: 'yds:demo', name: 'demo', kind: 'spring' }]
60
+ writeFileSync(configPath, JSON.stringify(doc))
61
+ return { changed: true, reason: 'added' }
62
+ }
63
+ const stop = watchWorkspace(ws, { repair })
64
+ try {
65
+ await new Promise((resolve) => setTimeout(resolve, 200))
66
+ // ris-ide regenerates the file without our row
67
+ writeFileSync(configPath, '{"programs":[{"id":"frontend:x#start","name":"x","kind":"frontend"}]}')
68
+ for (let i = 0; i < 60; i += 1) {
69
+ await new Promise((resolve) => setTimeout(resolve, 100))
70
+ const rows = JSON.parse(readFileSync(configPath, 'utf8')).programs
71
+ if (rows.some((row) => row.id === 'yds:demo')) break
72
+ }
73
+ const rows = JSON.parse(readFile(configPath, 'utf8')).programs
74
+ assert.ok(rows.some((row) => row.id === 'yds:demo'), 'the launcher row is back')
75
+ } finally {
76
+ stop()
77
+ rmSync(ws, { recursive: true, force: true })
78
+ }
79
+ })
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The tunnel flags we add to .vscode/launch.json: written only while a tunnel
3
+ * exists, and taken back out when the proxy stops.
4
+ */
5
+ import assert from 'node:assert/strict'
6
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { test } from 'node:test'
10
+
11
+ import { LAUNCH_CONFIG, LAUNCH_ENTRY_NAME, applyLaunchConfig, ourLaunchEntry, proxyVmArgs, readLaunchConfig, stripLaunchProxy } from '../lib/ide.js'
12
+
13
+ /** A project dir with a user-owned launch.json that must survive untouched. */
14
+ function project(withEntry = false) {
15
+ const dir = mkdtempSync(join(tmpdir(), 'yds-launch-'))
16
+ mkdirSync(join(dir, '.vscode'), { recursive: true })
17
+ const doc = {
18
+ version: '0.2.0',
19
+ configurations: [{ type: 'java', name: 'MDFApplication', mainClass: 'x.Y', vmArgs: ['-Xmx512m'] }],
20
+ }
21
+ if (withEntry) {
22
+ doc.configurations.push({ type: 'java', name: LAUNCH_ENTRY_NAME, vmArgs: ['-Dyms.dev=true'] })
23
+ }
24
+ writeFileSync(join(dir, LAUNCH_CONFIG), `${JSON.stringify(doc, null, 2)}\n`)
25
+ return dir
26
+ }
27
+
28
+ const cfg = (dir, socks) => ({ projectDir: dir, workspace: dir, socks })
29
+
30
+ test('proxy flags are derived from .env SOCKS and absent without one', () => {
31
+ assert.deepEqual(proxyVmArgs({ socks: { host: '10.3.91.121', port: 1080 } }), [
32
+ '-DsocksProxyHost=10.3.91.121',
33
+ '-DsocksProxyPort=1080',
34
+ '-Djava.net.useSystemProxies=false',
35
+ ])
36
+ assert.deepEqual(proxyVmArgs({ socks: null }), [], 'no tunnel, no flags')
37
+ assert.deepEqual(proxyVmArgs({}), [])
38
+ })
39
+
40
+ test('apply writes the flags for our entry and leaves other configurations alone', () => {
41
+ const dir = project()
42
+ try {
43
+ const result = applyLaunchConfig(cfg(dir, { host: '10.3.91.121', port: 1080 }))
44
+ assert.equal(result.changed, true)
45
+ const doc = readLaunchConfig(dir).parsed
46
+ const ours = doc.configurations.find((entry) => entry.name === LAUNCH_ENTRY_NAME)
47
+ assert.ok(ours.vmArgs.includes('-DsocksProxyHost=10.3.91.121'))
48
+ const user = doc.configurations.find((entry) => entry.name === 'MDFApplication')
49
+ assert.deepEqual(user.vmArgs, ['-Xmx512m'], 'the user configuration must be byte-identical')
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true })
52
+ }
53
+ })
54
+
55
+ test('stripping removes exactly the proxy flags and is a no-op afterwards', () => {
56
+ const dir = project()
57
+ try {
58
+ applyLaunchConfig(cfg(dir, { host: '10.3.91.121', port: 1080 }))
59
+ const first = stripLaunchProxy(cfg(dir, null))
60
+ assert.equal(first.changed, true)
61
+ assert.equal(first.action, 'stripped')
62
+ const ours = readLaunchConfig(dir).parsed.configurations.find((entry) => entry.name === LAUNCH_ENTRY_NAME)
63
+ assert.equal(ours.vmArgs.filter((value) => /socksProxy|useSystemProxies/.test(value)).length, 0)
64
+ assert.deepEqual(ours.vmArgs, ourLaunchEntry({}).vmArgs, 'the entry is back to its canonical (proxy-free) form')
65
+ assert.equal(stripLaunchProxy(cfg(dir, null)).changed, false, 'nothing left to strip')
66
+ // The user's configuration is still untouched by either side of the cycle.
67
+ assert.deepEqual(readLaunchConfig(dir).parsed.configurations.find((entry) => entry.name === 'MDFApplication').vmArgs, ['-Xmx512m'])
68
+ } finally {
69
+ rmSync(dir, { recursive: true, force: true })
70
+ }
71
+ })
72
+
73
+ test('stripping a file without our entry does nothing', () => {
74
+ const dir = project()
75
+ try {
76
+ const result = stripLaunchProxy(cfg(dir, null))
77
+ assert.equal(result.changed, false)
78
+ assert.equal(result.action, 'not-present')
79
+ assert.equal(readFileSync(join(dir, LAUNCH_CONFIG), 'utf8').includes(LAUNCH_ENTRY_NAME), false)
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true })
82
+ }
83
+ })
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The two launch modes must never be mixed:
3
+ * - proxy : YonCode's JVM carries -DsocksProxyHost/Port and reads
4
+ * yms-home/config (rewritten to 127.0.0.1).
5
+ * - direct : no proxy flag at all; when dsh still tunnels, YonCode reads the
6
+ * untouched copy in yms-home/config-direct.
7
+ * These tests pin the boundaries: flags, config dirs, mode switching, and the
8
+ * default derived from .env `ide.launch`.
9
+ */
10
+ import assert from 'node:assert/strict'
11
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { test } from 'node:test'
15
+
16
+ import { adaptAndWriteConfig } from '../lib/confcenter.js'
17
+ import { resolveConfig } from '../lib/config.js'
18
+ import { LAUNCH_ENTRY_NAME, applyLaunchConfig, launchModeVmArgs, ourLaunchEntry, readLaunchConfig } from '../lib/ide.js'
19
+
20
+ const SOCKS = { host: '10.3.91.121', port: 1080 }
21
+
22
+ function workspace(env = '') {
23
+ const dir = mkdtempSync(join(tmpdir(), 'yds-modes-'))
24
+ if (env !== '') writeFileSync(join(dir, '.env'), env, 'utf8')
25
+ return dir
26
+ }
27
+
28
+ function launchProject() {
29
+ const dir = mkdtempSync(join(tmpdir(), 'yds-launch-mode-'))
30
+ mkdirSync(join(dir, '.vscode'), { recursive: true })
31
+ writeFileSync(join(dir, '.vscode', 'launch.json'), `${JSON.stringify({ version: '0.2.0', configurations: [] }, null, 2)}\n`)
32
+ return dir
33
+ }
34
+
35
+ const entryVmArgs = (dir) => readLaunchConfig(dir).parsed.configurations.find((c) => c.name === LAUNCH_ENTRY_NAME).vmArgs
36
+
37
+ test('proxy mode: socks flags, no local_conf_path', () => {
38
+ const flags = launchModeVmArgs({ launchMode: 'proxy', socks: SOCKS })
39
+ assert.deepEqual(flags, [
40
+ '-DsocksProxyHost=10.3.91.121',
41
+ '-DsocksProxyPort=1080',
42
+ '-Djava.net.useSystemProxies=false',
43
+ ])
44
+ const entry = ourLaunchEntry({ launchMode: 'proxy', socks: SOCKS })
45
+ assert.ok(entry.vmArgs.includes('-DsocksProxyHost=10.3.91.121'))
46
+ assert.ok(!entry.vmArgs.some((v) => String(v).startsWith('-Dlocal_conf_path=')), 'proxy mode must not read config-direct')
47
+ })
48
+
49
+ test('direct mode on a tunneled machine: local_conf_path only, never socks flags', () => {
50
+ const flags = launchModeVmArgs({ launchMode: 'direct', socks: SOCKS })
51
+ assert.equal(flags.length, 2)
52
+ assert.ok(flags[0].startsWith('-Dlocal_conf_path=') && flags[0].endsWith('/config-direct/'), `unexpected: ${flags[0]}`)
53
+ assert.equal(flags[1], '-Dyms_use_local_conf=true')
54
+ const entry = ourLaunchEntry({ launchMode: 'direct', socks: SOCKS })
55
+ assert.ok(!entry.vmArgs.some((v) => /^-DsocksProxy/.test(String(v))), 'direct mode must never carry proxy flags')
56
+ assert.ok(!entry.vmArgs.includes('-Djava.net.useSystemProxies=false'))
57
+ })
58
+
59
+ test('direct mode without SOCKS needs no override at all', () => {
60
+ assert.deepEqual(launchModeVmArgs({ launchMode: 'direct', socks: null }), [])
61
+ assert.deepEqual(launchModeVmArgs({ launchMode: 'proxy', socks: null }), [])
62
+ })
63
+
64
+ test('switching modes rewrites the entry with no residue from the other mode', () => {
65
+ const dir = launchProject()
66
+ try {
67
+ const proxyCfg = { projectDir: dir, workspace: dir, launchMode: 'proxy', socks: SOCKS }
68
+ applyLaunchConfig(proxyCfg)
69
+ assert.ok(entryVmArgs(dir).includes('-DsocksProxyHost=10.3.91.121'))
70
+
71
+ const directCfg = { ...proxyCfg, launchMode: 'direct' }
72
+ applyLaunchConfig(directCfg)
73
+ const direct = entryVmArgs(dir)
74
+ assert.ok(!direct.some((v) => /^-DsocksProxy/.test(String(v))))
75
+ assert.ok(direct.some((v) => String(v).startsWith('-Dlocal_conf_path=')))
76
+
77
+ applyLaunchConfig(proxyCfg)
78
+ const back = entryVmArgs(dir)
79
+ assert.ok(back.includes('-DsocksProxyHost=10.3.91.121'))
80
+ assert.ok(!back.some((v) => String(v).startsWith('-Dlocal_conf_path=')), 'stale direct override after switching back')
81
+ } finally {
82
+ rmSync(dir, { recursive: true, force: true })
83
+ }
84
+ })
85
+
86
+ test('config-direct keeps the real endpoints while config/ stays adapted', () => {
87
+ const dir = workspace()
88
+ try {
89
+ const cfg = {
90
+ workspace: dir,
91
+ stateDir: join(dir, '.dsh-ris-yds'),
92
+ ymsHome: join(dir, 'yms-home'),
93
+ configDir: join(dir, 'yms-home', 'config'),
94
+ configDirectDir: join(dir, 'yms-home', 'config-direct'),
95
+ ymsPort: 8080,
96
+ socks: SOCKS,
97
+ dbForwardPort: 1521,
98
+ redisForwardPort: 6890,
99
+ module: 'c-ass-kk-hans',
100
+ context: '/',
101
+ serverBindAddress: '172.20.63.131',
102
+ serverPort: 31010,
103
+ }
104
+ mkdirSync(cfg.stateDir, { recursive: true })
105
+ const files = {
106
+ 'yms_middleware.json': JSON.stringify({
107
+ connectionPools: { pool1: { url: 'jdbc:oracle:thin:@//10.3.91.100:31000/ris' } },
108
+ 'redis.pools': { redis1: { host: '172.20.235.81', port: 6890 } },
109
+ }),
110
+ 'module_config.json': '{"module":true}',
111
+ 'yonbip_config.json': '{"platform":true}',
112
+ }
113
+
114
+ adaptAndWriteConfig(cfg, files)
115
+ adaptAndWriteConfig(cfg, files, { rewrite: false, dir: cfg.configDirectDir })
116
+
117
+ const adapted = readFileSync(join(cfg.configDir, 'yms_middleware.json'), 'utf8')
118
+ const untouched = readFileSync(join(cfg.configDirectDir, 'yms_middleware.json'), 'utf8')
119
+ assert.ok(adapted.includes('127.0.0.1:1521'), 'proxy config must point at the local forwarder')
120
+ assert.ok(adapted.includes('127.0.0.1'), 'proxy config must not keep the real DB host')
121
+ assert.ok(!adapted.includes('10.3.91.100:31000'))
122
+ assert.ok(untouched.includes('10.3.91.100:31000'), 'direct config must keep the real DB host')
123
+ assert.ok(!untouched.includes('127.0.0.1:1521'))
124
+ // the two writes did not leak into each other's directory
125
+ assert.ok(readFileSync(join(cfg.configDir, 'yms_middleware.json'), 'utf8').includes('127.0.0.1:1521'))
126
+ } finally {
127
+ rmSync(dir, { recursive: true, force: true })
128
+ }
129
+ })
130
+
131
+ test('ide.launch selects the mode; an invalid value is reported and falls back to proxy', () => {
132
+ const direct = workspace('ide.launch=direct\n')
133
+ const bogus = workspace('ide.launch=both\n')
134
+ try {
135
+ const d = resolveConfig(direct)
136
+ assert.equal(d.config.launchMode, 'direct')
137
+ assert.ok(d.config.configDirectDir.endsWith('config-direct'))
138
+ assert.notEqual(d.config.configDir, d.config.configDirectDir)
139
+
140
+ const b = resolveConfig(bogus)
141
+ assert.equal(b.config.launchMode, 'proxy', 'unknown values must not become direct silently')
142
+ assert.ok(b.problems.some((p) => p.includes('ide.launch')), `missing problem: ${b.problems.join(' | ')}`)
143
+ } finally {
144
+ rmSync(direct, { recursive: true, force: true })
145
+ rmSync(bogus, { recursive: true, force: true })
146
+ }
147
+ })
@@ -0,0 +1,118 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import net from 'node:net'
7
+
8
+ import { run } from '../lib/cli.js'
9
+
10
+ // The suite inherits the developer's shell: strip every YDS_* override, then
11
+ // force the "dsh-ris-ide is not installed" path — the bundled SOCKS forwarder
12
+ // is the only tunnel left, which is exactly the standalone (public plugin)
13
+ // shape.
14
+ for (const key of Object.keys(process.env)) {
15
+ if (key.startsWith('YDS_')) delete process.env[key]
16
+ }
17
+ process.env.YDS_IDE_PROXY = 'none'
18
+
19
+ const DB_PORT = 24531
20
+ const REDIS_PORT = 26891
21
+
22
+ const MIDDLEWARE = {
23
+ connectionPools: {
24
+ pool1: { url: 'jdbc:oracle:thin:@//172.20.235.80:1521/yonbipdev' },
25
+ },
26
+ 'redis.pools': {
27
+ redis1: { host: '172.20.235.81', port: 6890 },
28
+ },
29
+ }
30
+
31
+ function ok(result) {
32
+ // text() reports success without an exitCode; fail() sets 1.
33
+ assert.equal(result.exitCode ?? 0, 0, result.text)
34
+ }
35
+
36
+ function portOpen(port) {
37
+ return new Promise((resolve) => {
38
+ const socket = net.connect(port, '127.0.0.1')
39
+ socket.once('connect', () => { socket.destroy(); resolve(true) })
40
+ socket.once('error', () => resolve(false))
41
+ })
42
+ }
43
+
44
+ /** A workspace with a cached confcenter pull; `socks` adds the tunnel .env. */
45
+ function workspace({ socks }) {
46
+ const dir = mkdtempSync(join(tmpdir(), 'yds-standalone-'))
47
+ mkdirSync(join(dir, '.dsh-ris-yds', 'confcenter'), { recursive: true })
48
+ writeFileSync(join(dir, '.dsh-ris-yds', 'yms-port'), '65001')
49
+ writeFileSync(join(dir, '.dsh-ris-yds', 'confcenter', 'yms_middleware.json'), JSON.stringify(MIDDLEWARE))
50
+ if (socks === true) {
51
+ writeFileSync(join(dir, '.env'), [
52
+ 'socks.host = 127.0.0.1',
53
+ 'socks.port = 1080',
54
+ `socks.dbPort = ${DB_PORT}`,
55
+ `socks.redisPort = ${REDIS_PORT}`,
56
+ '',
57
+ ].join('\n'))
58
+ }
59
+ return dir
60
+ }
61
+
62
+ test('without dsh-ris-ide a direct workspace needs no tunnel at all', async () => {
63
+ const dir = workspace({ socks: false })
64
+ try {
65
+ const sync = await run(['proxy', 'sync'], { cwd: dir })
66
+ ok(sync)
67
+ assert.match(sync.text, /直连模式/)
68
+ const status = await run(['proxy', 'status'], { cwd: dir })
69
+ ok(status)
70
+ assert.match(status.text, /代理引擎不可用/)
71
+ assert.match(status.text, /不适用(未配置 SOCKS,直连模式)/)
72
+ // No tunnel => the sibling plugin has nothing to offer, so no hint either.
73
+ const doctor = await run(['doctor'], { cwd: dir })
74
+ ok(doctor)
75
+ assert.doesNotMatch(doctor.text, /未检测到 dsh-ris-ide/)
76
+ assert.equal(await portOpen(DB_PORT), false)
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true })
79
+ }
80
+ })
81
+
82
+ test('without dsh-ris-ide the bundled forwarder replaces the borrowed engine', async () => {
83
+ const dir = workspace({ socks: true })
84
+ try {
85
+ const sync = await run(['proxy', 'sync'], { cwd: dir })
86
+ ok(sync)
87
+ assert.match(sync.text, /代理引擎不可用/)
88
+ assert.match(sync.text, /内置 socks 转发器已启动/)
89
+ assert.equal(await portOpen(DB_PORT), true, 'Oracle 兜底端口由内置转发器监听')
90
+ assert.equal(await portOpen(REDIS_PORT), true, 'Redis 兜底端口由内置转发器监听')
91
+
92
+ const status = await run(['proxy', 'status'], { cwd: dir })
93
+ ok(status)
94
+ assert.match(status.text, /内置转发器: 运行中/)
95
+ assert.match(status.text, /172\.20\.235\.80:1521/)
96
+
97
+ // Tunnel without the borrowed engine: recommend the sibling plugin once, as
98
+ // an optional upgrade — never as a broken setup.
99
+ const doctor = await run(['doctor'], { cwd: dir })
100
+ ok(doctor)
101
+ assert.match(doctor.text, /未检测到 dsh-ris-ide(可选)/)
102
+ assert.match(doctor.text, /启停本地服务/)
103
+
104
+ // Starting twice is idempotent (no duplicate process / EADDRINUSE).
105
+ const again = await run(['proxy', 'start'], { cwd: dir })
106
+ ok(again)
107
+ assert.match(again.text, /已在运行/)
108
+
109
+ const stop = await run(['proxy', 'stop'], { cwd: dir })
110
+ ok(stop)
111
+ assert.match(stop.text, /内置转发器已停止/)
112
+ assert.equal(await portOpen(DB_PORT), false)
113
+ assert.equal(await portOpen(REDIS_PORT), false)
114
+ } finally {
115
+ await run(['proxy', 'stop'], { cwd: dir }).catch(() => {})
116
+ rmSync(dir, { recursive: true, force: true })
117
+ }
118
+ })