dsh-ris-yds 0.0.0-stage → 1.1.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.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/assets/yds-chrome-resource-override.crx +0 -0
- package/bin/check-listing.mjs +247 -0
- package/bin/yds.js +19 -0
- package/cordis.patch.yml +13 -0
- package/lib/browser.js +428 -0
- package/lib/build.js +115 -0
- package/lib/cli.js +1130 -0
- package/lib/confcenter.js +377 -0
- package/lib/config.js +552 -0
- package/lib/fe-dev.js +206 -0
- package/lib/ide-proxy.js +906 -0
- package/lib/ide-watch.js +188 -0
- package/lib/ide.js +728 -0
- package/lib/index.js +267 -0
- package/lib/proc.js +270 -0
- package/lib/risd-peer.js +353 -0
- package/lib/rules.js +193 -0
- package/lib/socks.js +279 -0
- package/lib/token.js +36 -0
- package/lib/yms.js +336 -0
- package/lib/yoncode.js +249 -0
- package/native/yds-rust.darwin-arm64.node +0 -0
- package/native/yds-rust.darwin-x64.node +0 -0
- package/native/yds-rust.linux-arm64.node +0 -0
- package/native/yds-rust.linux-x64.node +0 -0
- package/native/yds-rust.win32-arm64.node +0 -0
- package/native/yds-rust.win32-x64.node +0 -0
- package/package.json +58 -4
- package/skills/dsh-ris-yds/SKILL.md +134 -0
- package/test/browser.test.mjs +442 -0
- package/test/cli-stop.test.mjs +40 -0
- package/test/cli.test.mjs +246 -0
- package/test/config.test.mjs +106 -0
- package/test/fe-dev.test.mjs +93 -0
- package/test/hints.test.mjs +13 -0
- package/test/ide-config.test.mjs +155 -0
- package/test/ide-proxy.test.mjs +256 -0
- package/test/ide-watch.test.mjs +79 -0
- package/test/launch-proxy.test.mjs +83 -0
- package/test/lease-release.test.mjs +76 -0
- package/test/mode-separation.test.mjs +147 -0
- package/test/pidfile.test.mjs +105 -0
- package/test/proc.test.mjs +172 -0
- package/test/proxy-standalone.test.mjs +121 -0
- package/test/risd-leases.test.mjs +111 -0
- package/test/risd-tier.test.mjs +361 -0
- package/test/rules-e2e.test.mjs +122 -0
- package/test/rules.test.mjs +154 -0
- package/test/yoncode.test.mjs +159 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Tier 1 of the proxy stack: a running risd daemon. These tests run a FAKE daemon
|
|
2
|
+
// over HTTP (no real engine, no second tunnel) and assert the contract yds relies
|
|
3
|
+
// on: discovery, CAS rule writes with one retry, a lease, and a stop that withdraws
|
|
4
|
+
// yds' share WITHOUT stopping the machine-shared engine.
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { createServer } from 'node:http'
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
8
|
+
import net from 'node:net'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { after, before, test } from 'node:test'
|
|
12
|
+
|
|
13
|
+
import { run } from '../lib/cli.js'
|
|
14
|
+
|
|
15
|
+
const WS = '/tmp/risd-tier-ws'
|
|
16
|
+
const state = {
|
|
17
|
+
revision: 'rev1',
|
|
18
|
+
forcedConflicts: 0,
|
|
19
|
+
posts: [],
|
|
20
|
+
actions: [],
|
|
21
|
+
leases: [],
|
|
22
|
+
nextLease: 1,
|
|
23
|
+
deletes: [],
|
|
24
|
+
// Both are flipped by the tests below to exercise the client's defensive paths.
|
|
25
|
+
omitRevision: false,
|
|
26
|
+
engine: { pid: 4242, running: false, listen: [{ port: 24531, open: true }] },
|
|
27
|
+
}
|
|
28
|
+
let server
|
|
29
|
+
let home
|
|
30
|
+
let port
|
|
31
|
+
|
|
32
|
+
function send(res, status, body) {
|
|
33
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
34
|
+
res.end(JSON.stringify(body))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readBody(req) {
|
|
38
|
+
const chunks = []
|
|
39
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
40
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
41
|
+
return raw === '' ? {} : JSON.parse(raw)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
before(async () => {
|
|
45
|
+
mkdirSync(WS, { recursive: true })
|
|
46
|
+
home = mkdtempSync(join(tmpdir(), 'yds-risd-tier-'))
|
|
47
|
+
server = createServer(async (req, res) => {
|
|
48
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`)
|
|
49
|
+
if (req.method === 'GET' && url.pathname === '/v1/health') {
|
|
50
|
+
return send(res, 200, { ok: true, apiVersion: 1, version: 'test', pid: process.pid, port })
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'GET' && url.pathname === '/v1/proxy') {
|
|
53
|
+
return send(res, 200, {
|
|
54
|
+
ok: true,
|
|
55
|
+
...(state.omitRevision === true ? {} : { revision: state.revision }),
|
|
56
|
+
rules: [],
|
|
57
|
+
running: false,
|
|
58
|
+
listenPort: 11081,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
if (req.method === 'POST' && url.pathname === '/v1/proxy') {
|
|
62
|
+
const body = await readBody(req)
|
|
63
|
+
state.posts.push(body)
|
|
64
|
+
if (state.forcedConflicts > 0) {
|
|
65
|
+
state.forcedConflicts -= 1
|
|
66
|
+
state.revision = 'rev2'
|
|
67
|
+
return send(res, 409, { ok: false, conflict: true, code: 'conflict', message: 'conflict', revision: state.revision })
|
|
68
|
+
}
|
|
69
|
+
state.revision = 'rev3'
|
|
70
|
+
return send(res, 200, { ok: true, revision: state.revision, reloadNote: '' })
|
|
71
|
+
}
|
|
72
|
+
if (req.method === 'POST' && url.pathname === '/v1/leases') {
|
|
73
|
+
const body = await readBody(req)
|
|
74
|
+
const id = `lease-${state.nextLease++}`
|
|
75
|
+
state.leases.push({ id, ownerPid: body.ownerPid })
|
|
76
|
+
return send(res, 200, { ok: true, lease: { id } })
|
|
77
|
+
}
|
|
78
|
+
if (req.method === 'DELETE' && url.pathname.startsWith('/v1/leases/')) {
|
|
79
|
+
const id = url.pathname.split('/').pop()
|
|
80
|
+
state.deletes.push(id)
|
|
81
|
+
return send(res, 200, { ok: true })
|
|
82
|
+
}
|
|
83
|
+
if (req.method === 'POST' && url.pathname === '/v1/action') {
|
|
84
|
+
const body = await readBody(req)
|
|
85
|
+
state.actions.push(body)
|
|
86
|
+
return send(res, 200, { ok: true, result: { ok: true, pid: 4242 } })
|
|
87
|
+
}
|
|
88
|
+
if (req.method === 'GET' && url.pathname === '/v1/engine') {
|
|
89
|
+
return send(res, 200, { ok: true, engine: state.engine })
|
|
90
|
+
}
|
|
91
|
+
return send(res, 404, { ok: false, code: 'not-found' })
|
|
92
|
+
})
|
|
93
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
94
|
+
port = server.address().port
|
|
95
|
+
writeFileSync(join(home, 'risd.json'), `${JSON.stringify({ apiVersion: 1, version: 'test', pid: process.pid, port, token: 'test-token' })}\n`, { mode: 0o600 })
|
|
96
|
+
// A pre-existing global table: one user row and two yds rows written by an older
|
|
97
|
+
// version (no `owners` — yds rows are not attributed to a project any more).
|
|
98
|
+
writeFileSync(join(home, 'proxy.json'), `${JSON.stringify({
|
|
99
|
+
socks: { host: '10.0.0.1', port: 1080 },
|
|
100
|
+
listenPort: 11081,
|
|
101
|
+
rules: [
|
|
102
|
+
{ id: 'user-1', program: 'navicat.exe', hosts: '172.16.0.1', ports: '*', enabled: true, source: 'user', workspace: '/elsewhere' },
|
|
103
|
+
{ id: 'yds-old', program: 'java.exe', hosts: '172.16.0.2', ports: '*', enabled: true, source: 'yds', workspace: WS },
|
|
104
|
+
{ id: 'yds-other', program: 'java.exe', hosts: '172.17.9.*', ports: '*', enabled: true, source: 'yds', workspace: '/elsewhere' },
|
|
105
|
+
],
|
|
106
|
+
shadows: [],
|
|
107
|
+
}, null, 2)}\n`, { mode: 0o600 })
|
|
108
|
+
process.env.DSH_RIS_IDE_PROXY_HOME = home
|
|
109
|
+
process.env.YDS_IDE_PROXY = 'none'
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
after(() => {
|
|
113
|
+
server.close()
|
|
114
|
+
rmSync(home, { recursive: true, force: true })
|
|
115
|
+
rmSync(join(home, '..', '.dsh-ris-yds'), { recursive: true, force: true })
|
|
116
|
+
rmSync(WS, { recursive: true, force: true })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
function cfg() {
|
|
120
|
+
return { workspace: WS, socks: { host: '1.2.3.4', port: 1080 }, git: null, proxyJavaRules: true }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test('discovery prefers the daemon and honours YDS_RISD=none', async () => {
|
|
124
|
+
const { probeRisd } = await import('../lib/risd-peer.js')
|
|
125
|
+
const found = await probeRisd({ home })
|
|
126
|
+
assert.equal(found.usable, true)
|
|
127
|
+
assert.equal(found.state.port, port)
|
|
128
|
+
process.env.YDS_RISD = 'none'
|
|
129
|
+
const disabled = await probeRisd({ home })
|
|
130
|
+
assert.equal(disabled.usable, false)
|
|
131
|
+
assert.equal(disabled.reason, 'disabled')
|
|
132
|
+
delete process.env.YDS_RISD
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a rule write sends the complete table: user rows kept, every yds row replaced', async () => {
|
|
136
|
+
const { probeRisd, putRules } = await import('../lib/risd-peer.js')
|
|
137
|
+
const { ydsRulesFor } = await import('../lib/ide-proxy.js')
|
|
138
|
+
const found = await probeRisd({ home })
|
|
139
|
+
state.forcedConflicts = 0
|
|
140
|
+
const rules = ydsRulesFor(cfg(), [{ host: '10.0.0.5', port: 1521 }])
|
|
141
|
+
const saved = await putRules(WS, rules, { state: found.state })
|
|
142
|
+
assert.equal(saved.ok, true)
|
|
143
|
+
const posted = state.posts[state.posts.length - 1]
|
|
144
|
+
assert.equal(posted.revision, 'rev1')
|
|
145
|
+
// The daemon replaces the whole table with `config.rules`: there is no per-source
|
|
146
|
+
// retire flag and no workspace share — the payload IS the next table.
|
|
147
|
+
assert.equal('replaceRulesFrom' in posted, false)
|
|
148
|
+
const ids = posted.config.rules.map((rule) => rule.id)
|
|
149
|
+
assert.ok(ids.includes('user-1'), 'the user row is carried by the writer')
|
|
150
|
+
assert.ok(!ids.includes('yds-old'), 'every previous yds row is gone, not unioned')
|
|
151
|
+
assert.ok(!ids.includes('yds-other'))
|
|
152
|
+
assert.ok(ids.includes('yds-java'), 'the fresh yds set is the yds share')
|
|
153
|
+
const java = posted.config.rules.find((rule) => rule.id === 'yds-java')
|
|
154
|
+
assert.equal(java.hosts, '10.0.0.*', 'hosts come from this run only, never from an older row')
|
|
155
|
+
assert.equal(java.side, 'windows')
|
|
156
|
+
if (process.platform !== 'win32') {
|
|
157
|
+
assert.ok(ids.includes('yds-java-local'), 'non-Windows hosts also need the WSL-side row')
|
|
158
|
+
const local = posted.config.rules.find((rule) => rule.id === 'yds-java-local')
|
|
159
|
+
assert.equal(local.program, 'java')
|
|
160
|
+
assert.equal(local.side, 'local')
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('a revision conflict is retried once with the fresh revision', async () => {
|
|
165
|
+
const { probeRisd, putRules } = await import('../lib/risd-peer.js')
|
|
166
|
+
const { ydsRulesFor } = await import('../lib/ide-proxy.js')
|
|
167
|
+
const found = await probeRisd({ home })
|
|
168
|
+
state.revision = 'rev1'
|
|
169
|
+
state.forcedConflicts = 1
|
|
170
|
+
state.posts = []
|
|
171
|
+
const saved = await putRules(WS, ydsRulesFor(cfg(), []), { state: found.state, shadows: [] })
|
|
172
|
+
assert.equal(saved.ok, true)
|
|
173
|
+
assert.equal(state.posts.length, 2)
|
|
174
|
+
assert.equal(state.posts[0].revision, 'rev1')
|
|
175
|
+
assert.equal(state.posts[1].revision, 'rev2')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('ensureBorrowedProxy writes rules and starts the daemon engine', async () => {
|
|
179
|
+
const { ensureBorrowedProxy } = await import('../lib/ide-proxy.js')
|
|
180
|
+
state.actions = []
|
|
181
|
+
const result = await ensureBorrowedProxy(cfg(), [{ host: '10.0.0.5', port: 1521 }], { mapping: true })
|
|
182
|
+
assert.equal(result.mode, 'risd')
|
|
183
|
+
assert.equal(result.ok, true)
|
|
184
|
+
assert.equal(result.pid, 4242)
|
|
185
|
+
assert.deepEqual(state.actions.map((action) => action.action), ['start'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('yds stop releases the lease and removes every yds row, but keeps the shared engine', async () => {
|
|
189
|
+
const { holdBorrowedLease, stopBorrowedProxy } = await import('../lib/ide-proxy.js')
|
|
190
|
+
const held = await holdBorrowedLease(cfg(), { ownerPid: process.pid })
|
|
191
|
+
assert.equal(held.ok, true)
|
|
192
|
+
state.actions = []
|
|
193
|
+
state.posts = []
|
|
194
|
+
state.deletes = []
|
|
195
|
+
const stopped = await stopBorrowedProxy(cfg())
|
|
196
|
+
assert.equal(stopped.engine, 'kept')
|
|
197
|
+
assert.equal(stopped.mode, 'risd')
|
|
198
|
+
assert.deepEqual(state.deletes, [held.id])
|
|
199
|
+
assert.equal(state.actions.length, 0, 'never stops the machine-shared engine')
|
|
200
|
+
const posted = state.posts[state.posts.length - 1]
|
|
201
|
+
// The rule table is one global list: a stop clears the yds rows (including rows an
|
|
202
|
+
// older version wrote without owners) and carries the user's rows through intact.
|
|
203
|
+
// There is no "another project's share" to protect any more (no cross configuration).
|
|
204
|
+
assert.deepEqual(posted.config.rules.map((rule) => rule.id), ['user-1'])
|
|
205
|
+
assert.equal('replaceRulesFrom' in posted, false)
|
|
206
|
+
// The CLI reports what actually changed, not a blanket "rules withdrawn".
|
|
207
|
+
assert.deepEqual(stopped.withdrawn, { rules: 2, hosts: 2 })
|
|
208
|
+
assert.equal(existsSync(join(home, '..', '.dsh-ris-yds', 'risd-leases.json')), true)
|
|
209
|
+
const leases = JSON.parse(readFileSync(join(home, '..', '.dsh-ris-yds', 'risd-leases.json'), 'utf8'))
|
|
210
|
+
assert.equal(leases[WS], undefined)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a rule write never takes the daemon force path when the view has no revision', async () => {
|
|
214
|
+
const { putRules } = await import('../lib/risd-peer.js')
|
|
215
|
+
state.omitRevision = true
|
|
216
|
+
const posted = state.posts.length
|
|
217
|
+
try {
|
|
218
|
+
const saved = await putRules(WS, [], {})
|
|
219
|
+
assert.equal(saved.ok, false)
|
|
220
|
+
assert.match(saved.error, /revision-missing/)
|
|
221
|
+
assert.equal(state.posts.length, posted, 'no un-CASed write was attempted')
|
|
222
|
+
} finally {
|
|
223
|
+
state.omitRevision = false
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('a rule write sends only fields the daemon consumes (no dead shadows field)', async () => {
|
|
228
|
+
const { putRules } = await import('../lib/risd-peer.js')
|
|
229
|
+
const path = join(home, 'proxy.json')
|
|
230
|
+
const original = readFileSync(path, 'utf8')
|
|
231
|
+
const stored = JSON.parse(original)
|
|
232
|
+
stored.shadows = [{ host: '10.0.0.9', port: 1521 }]
|
|
233
|
+
writeFileSync(path, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 })
|
|
234
|
+
try {
|
|
235
|
+
state.posts = []
|
|
236
|
+
const saved = await putRules(WS, [], { gitHost: 'git.example.com' })
|
|
237
|
+
assert.equal(saved.ok, true)
|
|
238
|
+
// POST /v1/proxy ignores `config.shadows`; sending it implied a mapping update
|
|
239
|
+
// that could never happen (review D-P2-8).
|
|
240
|
+
assert.equal(state.posts[0].config.shadows, undefined)
|
|
241
|
+
assert.equal(state.posts[0].config.listenPort, 11081, 'other table fields survive')
|
|
242
|
+
assert.ok(Array.isArray(state.posts[0].config.rules), 'rules are still sent')
|
|
243
|
+
} finally {
|
|
244
|
+
writeFileSync(path, original, { mode: 0o600 })
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('borrowedProxyStatus reports the engine pid and listeners, not the config port', async () => {
|
|
249
|
+
const { borrowedProxyStatus } = await import('../lib/ide-proxy.js')
|
|
250
|
+
state.engine = { pid: 4242, running: true, listen: [{ port: 24531, open: true }] }
|
|
251
|
+
const st = await borrowedProxyStatus(cfg())
|
|
252
|
+
assert.equal(st.mode, 'risd')
|
|
253
|
+
assert.equal(st.running, true)
|
|
254
|
+
assert.equal(st.pid, 4242)
|
|
255
|
+
assert.deepEqual(st.listenPorts, [24531])
|
|
256
|
+
assert.equal(st.listenPort, 11081, 'the configured listenPort from the view, unchanged')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// CLI-level: what a user sees after `yds start` / `yds stop` in the risd tier.
|
|
261
|
+
// The workspace carries a SOCKS config, so the tunnel path is fully exercised.
|
|
262
|
+
|
|
263
|
+
const CLI_DB_PORT = 24631
|
|
264
|
+
const CLI_REDIS_PORT = 26901
|
|
265
|
+
const MIDDLEWARE = {
|
|
266
|
+
connectionPools: { pool1: { url: 'jdbc:oracle:thin:@//172.20.235.80:1521/yonbipdev' } },
|
|
267
|
+
'redis.pools': { redis1: { host: '172.20.235.81', port: 6890 } },
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function cliWorkspace() {
|
|
271
|
+
const dir = mkdtempSync(join(tmpdir(), 'yds-risd-cli-'))
|
|
272
|
+
mkdirSync(join(dir, '.dsh-ris-yds', 'confcenter'), { recursive: true })
|
|
273
|
+
writeFileSync(join(dir, '.dsh-ris-yds', 'yms-port'), '65002')
|
|
274
|
+
writeFileSync(join(dir, '.dsh-ris-yds', 'confcenter', 'yms_middleware.json'), JSON.stringify(MIDDLEWARE))
|
|
275
|
+
writeFileSync(join(dir, '.env'), [
|
|
276
|
+
'socks.host = 127.0.0.1',
|
|
277
|
+
'socks.port = 1080',
|
|
278
|
+
`socks.dbPort = ${CLI_DB_PORT}`,
|
|
279
|
+
`socks.redisPort = ${CLI_REDIS_PORT}`,
|
|
280
|
+
'',
|
|
281
|
+
].join('\n'))
|
|
282
|
+
return dir
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function portOpen(port) {
|
|
286
|
+
return new Promise((resolve) => {
|
|
287
|
+
const socket = net.connect(port, '127.0.0.1')
|
|
288
|
+
socket.once('connect', () => { socket.destroy(); resolve(true) })
|
|
289
|
+
socket.once('error', () => resolve(false))
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
test('status names the risd daemon engine even when this process cannot load dsh-ris-ide', async () => {
|
|
294
|
+
const dir = cliWorkspace()
|
|
295
|
+
try {
|
|
296
|
+
state.engine = { pid: 4242, running: true, listen: [{ port: 24531, open: true }] }
|
|
297
|
+
const status = await run(['status'], { cwd: dir })
|
|
298
|
+
assert.equal(status.exitCode ?? 0, 0, status.text)
|
|
299
|
+
assert.match(status.text, /运行中(risd daemon 引擎 pid 4242)/)
|
|
300
|
+
const proxy = await run(['proxy', 'status'], { cwd: dir })
|
|
301
|
+
assert.equal(proxy.exitCode ?? 0, 0, proxy.text)
|
|
302
|
+
// `loaded.path` is empty here (YDS_IDE_PROXY=none): the engine still has a name.
|
|
303
|
+
assert.match(proxy.text, /引擎: risd:\d+/)
|
|
304
|
+
assert.match(proxy.text, /运行: 是(pid 4242)/)
|
|
305
|
+
assert.match(proxy.text, /引擎监听 24531/)
|
|
306
|
+
} finally {
|
|
307
|
+
rmSync(dir, { recursive: true, force: true })
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('proxy start names the engine listeners and brings the bundled forwarder up', async () => {
|
|
312
|
+
const dir = cliWorkspace()
|
|
313
|
+
try {
|
|
314
|
+
state.engine = { pid: 4242, running: false, listen: [{ port: 24531, open: true }] }
|
|
315
|
+
const started = await run(['proxy', 'start'], { cwd: dir })
|
|
316
|
+
assert.equal(started.exitCode ?? 0, 0, started.text)
|
|
317
|
+
assert.match(started.text, /引擎监听 1 个端口(24531)/)
|
|
318
|
+
assert.match(started.text, /内置 socks 转发器已启动/)
|
|
319
|
+
assert.equal(await portOpen(CLI_DB_PORT), true, '127.0.0.1 兜底端口由内置转发器监听')
|
|
320
|
+
} finally {
|
|
321
|
+
await run(['proxy', 'stop'], { cwd: dir })
|
|
322
|
+
rmSync(dir, { recursive: true, force: true })
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('yds stop --keep-socks still releases the lease and withdraws rules, and defers the bridge reload', async () => {
|
|
327
|
+
const dir = cliWorkspace()
|
|
328
|
+
const { rememberLease } = await import('../lib/risd-peer.js')
|
|
329
|
+
try {
|
|
330
|
+
state.deletes = []
|
|
331
|
+
state.posts = []
|
|
332
|
+
// A lease recorded for this workspace, as `yds start` would have left it.
|
|
333
|
+
assert.equal(rememberLease(dir, { id: 'lease-cli', ownerPid: process.pid, port: 11081 }), true)
|
|
334
|
+
const stopped = await run(['stop', '--keep-socks'], { cwd: dir })
|
|
335
|
+
assert.equal(stopped.exitCode ?? 0, 0, stopped.text)
|
|
336
|
+
// --keep-socks never meant "keep the lease": the YMS pid is gone, so a held
|
|
337
|
+
// lease would keep the daemon's engine alive for a dead owner (D-P2-7).
|
|
338
|
+
assert.deepEqual(state.deletes, ['lease-cli'])
|
|
339
|
+
const posted = state.posts[state.posts.length - 1]
|
|
340
|
+
assert.equal('replaceRulesFrom' in posted, false, 'the save carries the whole table')
|
|
341
|
+
assert.equal(posted.config.rules.some((rule) => rule.source === 'yds'), false, 'the global table keeps no yds rows after a stop')
|
|
342
|
+
assert.match(stopped.text, /内置 socks 转发器:保留(--keep-socks)/)
|
|
343
|
+
// The daemon reloaded the bridge while saving the table; reloading it again
|
|
344
|
+
// from this short-lived CLI would fight the daemon's serialized reload (D-P2-10).
|
|
345
|
+
assert.match(stopped.text, /系统层拦截: 由 risd daemon 管理/)
|
|
346
|
+
assert.doesNotMatch(stopped.text, /未找到 dsh-ris-ide 的 ProxyBridge 适配器/)
|
|
347
|
+
} finally {
|
|
348
|
+
rmSync(dir, { recursive: true, force: true })
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('holdBorrowedLease drops a dead record for the same workspace before taking a new one', async () => {
|
|
353
|
+
const { readLease, rememberLease } = await import('../lib/risd-peer.js')
|
|
354
|
+
const { holdBorrowedLease } = await import('../lib/ide-proxy.js')
|
|
355
|
+
assert.equal(rememberLease(WS, { id: 'lease-dead', ownerPid: 999999, port: 1 }), true)
|
|
356
|
+
const lines = []
|
|
357
|
+
const held = await holdBorrowedLease(cfg(), { ownerPid: process.pid, log: (line) => lines.push(line) })
|
|
358
|
+
assert.equal(held.ok, true)
|
|
359
|
+
assert.ok(lines.some((line) => /已清理该 workspace 的死租约记录/.test(line)), lines.join('\n'))
|
|
360
|
+
assert.notEqual(readLease(WS)?.id, 'lease-dead', 'the dead record was replaced, not kept')
|
|
361
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// End-to-end round trip of the global rule table through the daemon contract: rules
|
|
2
|
+
// leave yds over HTTP and land in proxy.json. The stub below models the whole-table
|
|
3
|
+
// save — whatever `config.rules` the client sends IS the new table, so a yds write has
|
|
4
|
+
// to carry the user's rows itself and a stop has to send the table without the yds rows.
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { createServer } from 'node:http'
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), 'yds-e2e-'))
|
|
13
|
+
const WS_A = mkdtempSync(join(tmpdir(), 'yds-e2e-a-'))
|
|
14
|
+
const WS_B = mkdtempSync(join(tmpdir(), 'yds-e2e-b-'))
|
|
15
|
+
const USER_ROW = { id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', ports: '*', source: 'user' }
|
|
16
|
+
let revision = 'r1'
|
|
17
|
+
let server = null
|
|
18
|
+
let lastPost = null
|
|
19
|
+
|
|
20
|
+
function tablePath() { return join(home, 'proxy.json') }
|
|
21
|
+
|
|
22
|
+
function writeTable(rules) {
|
|
23
|
+
writeFileSync(tablePath(), `${JSON.stringify({
|
|
24
|
+
socks: { host: '', port: 0 }, listenPort: 11081, rules, shadows: [],
|
|
25
|
+
}, null, 2)}\n`, { mode: 0o600 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readTable() {
|
|
29
|
+
return existsSync(tablePath())
|
|
30
|
+
? JSON.parse(readFileSync(tablePath(), 'utf8'))
|
|
31
|
+
: { socks: { host: '', port: 0 }, listenPort: 11081, rules: [], shadows: [] }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test.before(async () => {
|
|
35
|
+
writeTable([USER_ROW])
|
|
36
|
+
server = createServer((req, res) => {
|
|
37
|
+
const send = (status, payload) => {
|
|
38
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
39
|
+
res.end(`${JSON.stringify(payload)}\n`)
|
|
40
|
+
}
|
|
41
|
+
if (req.method === 'GET' && req.url === '/v1/health') return send(200, { ok: true, apiVersion: 1, version: 'test' })
|
|
42
|
+
if (req.method === 'GET' && String(req.url).startsWith('/v1/proxy')) {
|
|
43
|
+
return send(200, { ok: true, revision, running: false, listenPort: 11081, rules: readTable().rules })
|
|
44
|
+
}
|
|
45
|
+
if (req.method === 'POST' && req.url === '/v1/proxy') {
|
|
46
|
+
let body = ''
|
|
47
|
+
req.on('data', (chunk) => { body += chunk })
|
|
48
|
+
req.on('end', () => {
|
|
49
|
+
const parsed = JSON.parse(body)
|
|
50
|
+
lastPost = parsed
|
|
51
|
+
if (String(parsed.revision).trim() !== '' && parsed.revision !== revision) {
|
|
52
|
+
return send(409, { ok: false, code: 'revision-conflict', revision })
|
|
53
|
+
}
|
|
54
|
+
const rules = parsed.config?.rules
|
|
55
|
+
if (!Array.isArray(rules)) return send(400, { ok: false, error: 'rules must be the final table' })
|
|
56
|
+
// Whole-table save: the payload IS the table (no per-source bookkeeping).
|
|
57
|
+
writeTable(rules)
|
|
58
|
+
revision = `r${Number(revision.slice(1)) + 1}`
|
|
59
|
+
return send(200, { ok: true, revision, reloadNote: '' })
|
|
60
|
+
})
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
return send(404, { ok: false })
|
|
64
|
+
})
|
|
65
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
66
|
+
writeFileSync(join(home, 'risd.json'), `${JSON.stringify({
|
|
67
|
+
apiVersion: 1, version: 'test', pid: process.pid, port: server.address().port, token: 'test-token',
|
|
68
|
+
})}\n`, { mode: 0o600 })
|
|
69
|
+
process.env.DSH_RIS_IDE_PROXY_HOME = home
|
|
70
|
+
process.env.YDS_IDE_PROXY = 'none'
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test.after(() => {
|
|
74
|
+
server?.close()
|
|
75
|
+
rmSync(home, { recursive: true, force: true })
|
|
76
|
+
rmSync(join(home, '..', '.dsh-ris-yds'), { recursive: true, force: true })
|
|
77
|
+
rmSync(WS_A, { recursive: true, force: true })
|
|
78
|
+
rmSync(WS_B, { recursive: true, force: true })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
function javaRow(hosts, side = 'windows') {
|
|
82
|
+
return { id: side === 'local' ? 'yds-java-local' : 'yds-java', program: side === 'local' ? 'java' : 'java.exe', side, hosts, ports: '*', enabled: true, shadow: false, source: 'yds' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('a start writes the complete table and a stop removes every yds row', async () => {
|
|
86
|
+
const { putRules } = await import('../lib/risd-peer.js')
|
|
87
|
+
|
|
88
|
+
const started = await putRules(WS_A, [javaRow('10.1.1.*')], { home })
|
|
89
|
+
assert.equal(started.ok, true)
|
|
90
|
+
const saved = readTable().rules
|
|
91
|
+
assert.deepEqual(saved.map((row) => row.id), ['user-1', 'yds-java'], 'the user row is still there')
|
|
92
|
+
assert.equal(saved[1].hosts, '10.1.1.*')
|
|
93
|
+
assert.equal('replaceRulesFrom' in (lastPost ?? {}), false, 'the whole-table save needs no per-source flag')
|
|
94
|
+
assert.deepEqual(lastPost.config.rules.map((row) => row.id), ['user-1', 'yds-java'])
|
|
95
|
+
|
|
96
|
+
const stopped = await putRules(WS_A, [], { home })
|
|
97
|
+
assert.equal(stopped.ok, true)
|
|
98
|
+
assert.deepEqual(stopped.withdrawn, { rules: 1, hosts: 1 })
|
|
99
|
+
assert.deepEqual(readTable().rules, [USER_ROW], 'the user row survives the stop, the yds rows do not')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('the table is global: the last start wins and a stop clears every yds row', async () => {
|
|
103
|
+
const { putRules } = await import('../lib/risd-peer.js')
|
|
104
|
+
await putRules(WS_A, [javaRow('10.1.1.*')], { home })
|
|
105
|
+
const bStart = await putRules(WS_B, [javaRow('10.2.2.*')], { home })
|
|
106
|
+
assert.equal(bStart.ok, true)
|
|
107
|
+
const table = readTable().rules
|
|
108
|
+
assert.equal(table.filter((row) => row.source === 'yds').length, 1, 'one global java row, not one per project')
|
|
109
|
+
assert.equal(table.find((row) => row.source === 'yds').hosts, '10.2.2.*', 'B replaces A; endpoints are not unioned')
|
|
110
|
+
|
|
111
|
+
const aStop = await putRules(WS_A, [], { home })
|
|
112
|
+
assert.equal(aStop.ok, true)
|
|
113
|
+
assert.deepEqual(readTable().rules, [USER_ROW], 'a stop owns every yds row, whichever project saved it')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('a stale revision is rejected and retried with the fresh view', async () => {
|
|
117
|
+
const { putRules } = await import('../lib/risd-peer.js')
|
|
118
|
+
revision = 'bumped-outside'
|
|
119
|
+
const saved = await putRules(WS_A, [javaRow('10.5.5.*')], { home })
|
|
120
|
+
assert.equal(saved.ok, true, 'the retry re-reads the view and lands')
|
|
121
|
+
assert.equal(readTable().rules.find((row) => row.source === 'yds').hosts, '10.5.5.*')
|
|
122
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The rule table is ONE global list and the panel saves it wholesale. yds owns exactly
|
|
2
|
+
// its `source: 'yds'` rows: every save replaces them as a set, a stop removes them all,
|
|
3
|
+
// and user/auto rows pass through untouched. The only thing that must not collapse is
|
|
4
|
+
// the program identity — `java.exe` (Windows image) and `java` (WSL/Linux process) are
|
|
5
|
+
// two different rows, which is what the `side`-aware group key pins down.
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
YDS_RULE_SOURCE,
|
|
11
|
+
mergeYdsRules,
|
|
12
|
+
programKeyOf,
|
|
13
|
+
ruleGroupKey,
|
|
14
|
+
withdrawnBetween,
|
|
15
|
+
ydsRowsOf,
|
|
16
|
+
} from '../lib/rules.js'
|
|
17
|
+
|
|
18
|
+
const fresh = (row) => ({ source: YDS_RULE_SOURCE, ports: '*', enabled: true, ...row })
|
|
19
|
+
|
|
20
|
+
test('program identity: image name, bare name and wildcard patterns', () => {
|
|
21
|
+
assert.equal(programKeyOf('java.exe'), 'java')
|
|
22
|
+
assert.equal(programKeyOf('C:\\Program Files\\YonCode\\devkit\\jdk21\\bin\\java.exe'), 'java')
|
|
23
|
+
assert.equal(programKeyOf('java'), 'java')
|
|
24
|
+
assert.equal(programKeyOf('git.cmd'), 'git')
|
|
25
|
+
assert.equal(programKeyOf(' git '), 'git')
|
|
26
|
+
// A wildcard has no basename identity: keep the pattern or every wildcard collapses.
|
|
27
|
+
assert.equal(programKeyOf('C:\\devkit\\jdk*\\bin\\*.exe'), 'raw:c:\\devkit\\jdk*\\bin\\*.exe')
|
|
28
|
+
assert.equal(programKeyOf('*.exe'), 'raw:*.exe')
|
|
29
|
+
assert.equal(programKeyOf(''), '')
|
|
30
|
+
assert.equal(programKeyOf(null), '')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('the group key keeps the two sides apart', () => {
|
|
34
|
+
assert.equal(ruleGroupKey({ side: 'windows', program: 'java.exe' }), 'windows|java')
|
|
35
|
+
assert.equal(ruleGroupKey({ side: 'local', program: 'java' }), 'local|java')
|
|
36
|
+
assert.notEqual(ruleGroupKey({ side: 'windows', program: 'java.exe' }), ruleGroupKey({ side: 'local', program: 'java' }))
|
|
37
|
+
// A row written without a side is its own bucket, never silently merged into a side.
|
|
38
|
+
assert.equal(ruleGroupKey({ program: 'git.exe' }), '|git')
|
|
39
|
+
assert.equal(ruleGroupKey({ side: ' WINDOWS ', program: 'git.exe' }), 'windows|git')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('non-yds rows pass through untouched, yds rows are replaced wholesale', () => {
|
|
43
|
+
const user = { id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', source: 'user' }
|
|
44
|
+
const auto = { id: 'auto-1', program: 'chrome.exe', hosts: '10.8.8.*', source: 'auto' }
|
|
45
|
+
const stored = [
|
|
46
|
+
user,
|
|
47
|
+
fresh({ id: 'yds-java-old', program: 'java.exe', hosts: '172.20.235.*', side: 'windows' }),
|
|
48
|
+
auto,
|
|
49
|
+
]
|
|
50
|
+
const table = mergeYdsRules(stored, [
|
|
51
|
+
fresh({ id: 'yds-java', program: 'java.exe', hosts: '172.20.63.*', side: 'windows' }),
|
|
52
|
+
])
|
|
53
|
+
assert.deepEqual(table[0], user, 'user rows are stored verbatim')
|
|
54
|
+
assert.deepEqual(table[1], auto, 'auto rows are stored verbatim')
|
|
55
|
+
assert.deepEqual(table.slice(2), [{
|
|
56
|
+
id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '172.20.63.*',
|
|
57
|
+
ports: '*', enabled: true, shadow: false, direct: false, source: YDS_RULE_SOURCE,
|
|
58
|
+
}])
|
|
59
|
+
assert.equal(table.some((row) => row.id === 'yds-java-old'), false, 'the old yds row is gone, not unioned')
|
|
60
|
+
// The caller's arrays are not mutated.
|
|
61
|
+
assert.deepEqual(stored.map((row) => row.id), ['user-1', 'yds-java-old', 'auto-1'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('one row per side+program: java.exe and java stay two rows', () => {
|
|
65
|
+
const table = mergeYdsRules([], [
|
|
66
|
+
fresh({ id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '172.20.235.*' }),
|
|
67
|
+
fresh({ id: 'yds-java-local', program: 'java', side: 'local', hosts: '172.20.235.*' }),
|
|
68
|
+
fresh({ id: 'yds-git', program: 'git.exe', side: 'windows', hosts: 'gitlab.example.com' }),
|
|
69
|
+
fresh({ id: 'yds-git-local', program: 'git', side: 'local', hosts: 'gitlab.example.com' }),
|
|
70
|
+
])
|
|
71
|
+
assert.deepEqual(table.map((row) => row.id), ['yds-java', 'yds-java-local', 'yds-git', 'yds-git-local'])
|
|
72
|
+
assert.deepEqual(table.map((row) => row.side), ['windows', 'local', 'windows', 'local'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('duplicate fresh rows for one side+program merge their hosts and keep the opt-ins safe', () => {
|
|
76
|
+
const table = mergeYdsRules([], [
|
|
77
|
+
fresh({ id: 'a', program: 'java.exe', side: 'windows', hosts: '10.1.1.*', enabled: true, shadow: true, direct: true }),
|
|
78
|
+
fresh({ id: 'b', program: 'C:\\devkit\\jdk21\\bin\\java.exe', side: 'windows', hosts: '10.2.2.*', enabled: false, shadow: false, direct: true }),
|
|
79
|
+
])
|
|
80
|
+
assert.equal(table.length, 1)
|
|
81
|
+
assert.equal(table[0].hosts, '10.1.1.*,10.2.2.*')
|
|
82
|
+
assert.equal(table[0].enabled, true, 'either copy enabling the row is enough')
|
|
83
|
+
assert.equal(table[0].shadow, false, 'an opt-in only survives when every copy asked for it')
|
|
84
|
+
assert.equal(table[0].direct, true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('an empty fresh list removes every yds row and keeps everything else', () => {
|
|
88
|
+
const stored = [
|
|
89
|
+
{ id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', source: 'user' },
|
|
90
|
+
fresh({ id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '10.1.1.*' }),
|
|
91
|
+
fresh({ id: 'yds-java-local', program: 'java', side: 'local', hosts: '10.1.1.*' }),
|
|
92
|
+
]
|
|
93
|
+
assert.deepEqual(mergeYdsRules(stored, []), [{ id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', source: 'user' }])
|
|
94
|
+
assert.deepEqual(mergeYdsRules(stored, undefined), mergeYdsRules(stored, []))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('invalid fresh rows are dropped and explained', () => {
|
|
98
|
+
const lines = []
|
|
99
|
+
const table = mergeYdsRules([], [
|
|
100
|
+
null,
|
|
101
|
+
'not a row',
|
|
102
|
+
fresh({ id: 'no-program', program: ' ' }),
|
|
103
|
+
fresh({ id: 'no-host', program: 'java.exe', hosts: '', ports: '' }),
|
|
104
|
+
fresh({ id: 'ok', program: 'git.exe', side: 'windows', hosts: 'gitlab.example.com' }),
|
|
105
|
+
], { log: (line) => lines.push(line) })
|
|
106
|
+
assert.deepEqual(table.map((row) => row.id), ['ok'])
|
|
107
|
+
assert.equal(lines.length, 4, 'each rejected row is logged: null, non-object, no program, no host/port')
|
|
108
|
+
assert.match(lines.join('\n'), /不是对象/)
|
|
109
|
+
assert.match(lines.join('\n'), /没有 program/)
|
|
110
|
+
assert.match(lines.join('\n'), /既没有 host 也没有 port/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('lists are canonicalized: hosts trimmed, ports unioned, `*` wins', () => {
|
|
114
|
+
const table = mergeYdsRules([], [
|
|
115
|
+
fresh({ id: 'one', program: 'java.exe', side: 'windows', hosts: ' 10.1.1.* , 10.2.2.* ', ports: '1521, 6890' }),
|
|
116
|
+
fresh({ id: 'two', program: 'git.exe', side: 'windows', hosts: 'gitlab.example.com', ports: '*' }),
|
|
117
|
+
])
|
|
118
|
+
assert.equal(table[0].hosts, '10.1.1.*,10.2.2.*')
|
|
119
|
+
assert.equal(table[0].ports, '1521,6890')
|
|
120
|
+
assert.equal(table[1].ports, '*')
|
|
121
|
+
const wild = mergeYdsRules([], [fresh({ id: 'w', program: 'java.exe', side: 'windows', hosts: '10.1.1.*', ports: '1521,*' })])
|
|
122
|
+
assert.equal(wild[0].ports, '*')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('fresh rows are always marked as yds rows', () => {
|
|
126
|
+
const table = mergeYdsRules([], [{ id: 'x', program: 'java.exe', hosts: '10.1.1.*' }])
|
|
127
|
+
assert.equal(table[0].source, YDS_RULE_SOURCE)
|
|
128
|
+
assert.equal(table[0].enabled, true, 'an omitted switch means enabled')
|
|
129
|
+
assert.equal(table[0].shadow, false)
|
|
130
|
+
assert.equal(table[0].direct, false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('wildcard programs never collapse onto one identity', () => {
|
|
134
|
+
const table = mergeYdsRules([], [
|
|
135
|
+
fresh({ id: 'star', program: 'C:\\a\\*.exe', hosts: '10.1.1.*' }),
|
|
136
|
+
fresh({ id: 'q', program: 'C:\\b\\?.exe', hosts: '10.2.2.*' }),
|
|
137
|
+
])
|
|
138
|
+
assert.deepEqual(table.map((row) => row.id), ['star', 'q'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('ydsRowsOf and withdrawnBetween report the yds share only', () => {
|
|
142
|
+
const user = { id: 'user-1', program: 'navicat.exe', hosts: '10.9.9.*', source: 'user' }
|
|
143
|
+
const before = [
|
|
144
|
+
user,
|
|
145
|
+
fresh({ id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '10.1.1.*,10.2.2.*' }),
|
|
146
|
+
fresh({ id: 'yds-git', program: 'git.exe', side: 'windows', hosts: 'gitlab.example.com' }),
|
|
147
|
+
]
|
|
148
|
+
const after = [user, fresh({ id: 'yds-java', program: 'java.exe', side: 'windows', hosts: '10.1.1.*' })]
|
|
149
|
+
assert.deepEqual(ydsRowsOf(before).map((row) => row.id), ['yds-java', 'yds-git'])
|
|
150
|
+
assert.deepEqual(withdrawnBetween(before, after), { rules: 1, hosts: 2 })
|
|
151
|
+
assert.deepEqual(withdrawnBetween(before, [user]), { rules: 2, hosts: 3 })
|
|
152
|
+
assert.deepEqual(withdrawnBetween(before, before), { rules: 0, hosts: 0 })
|
|
153
|
+
assert.deepEqual(withdrawnBetween(undefined, undefined), { rules: 0, hosts: 0 })
|
|
154
|
+
})
|