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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/assets/yds-chrome-resource-override.crx +0 -0
  4. package/bin/check-listing.mjs +247 -0
  5. package/bin/yds.js +19 -0
  6. package/cordis.patch.yml +13 -0
  7. package/lib/browser.js +428 -0
  8. package/lib/build.js +115 -0
  9. package/lib/cli.js +1130 -0
  10. package/lib/confcenter.js +377 -0
  11. package/lib/config.js +552 -0
  12. package/lib/fe-dev.js +206 -0
  13. package/lib/ide-proxy.js +906 -0
  14. package/lib/ide-watch.js +188 -0
  15. package/lib/ide.js +728 -0
  16. package/lib/index.js +267 -0
  17. package/lib/proc.js +270 -0
  18. package/lib/risd-peer.js +353 -0
  19. package/lib/rules.js +193 -0
  20. package/lib/socks.js +279 -0
  21. package/lib/token.js +36 -0
  22. package/lib/yms.js +336 -0
  23. package/lib/yoncode.js +249 -0
  24. package/native/yds-rust.darwin-arm64.node +0 -0
  25. package/native/yds-rust.darwin-x64.node +0 -0
  26. package/native/yds-rust.linux-arm64.node +0 -0
  27. package/native/yds-rust.linux-x64.node +0 -0
  28. package/native/yds-rust.win32-arm64.node +0 -0
  29. package/native/yds-rust.win32-x64.node +0 -0
  30. package/package.json +58 -4
  31. package/skills/dsh-ris-yds/SKILL.md +134 -0
  32. package/test/browser.test.mjs +442 -0
  33. package/test/cli-stop.test.mjs +40 -0
  34. package/test/cli.test.mjs +246 -0
  35. package/test/config.test.mjs +106 -0
  36. package/test/fe-dev.test.mjs +93 -0
  37. package/test/hints.test.mjs +13 -0
  38. package/test/ide-config.test.mjs +155 -0
  39. package/test/ide-proxy.test.mjs +256 -0
  40. package/test/ide-watch.test.mjs +79 -0
  41. package/test/launch-proxy.test.mjs +83 -0
  42. package/test/lease-release.test.mjs +76 -0
  43. package/test/mode-separation.test.mjs +147 -0
  44. package/test/pidfile.test.mjs +105 -0
  45. package/test/proc.test.mjs +172 -0
  46. package/test/proxy-standalone.test.mjs +121 -0
  47. package/test/risd-leases.test.mjs +111 -0
  48. package/test/risd-tier.test.mjs +361 -0
  49. package/test/rules-e2e.test.mjs +122 -0
  50. package/test/rules.test.mjs +154 -0
  51. package/test/yoncode.test.mjs +159 -0
@@ -0,0 +1,442 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ RDBGA_NAME,
6
+ CdpError,
7
+ classifyRdbgaCookies,
8
+ connectCdp,
9
+ cookiesOfTargets,
10
+ describeRdbgaCookies,
11
+ domainMatches,
12
+ hostVariants,
13
+ isPlausibleRdbga,
14
+ probeDevtoolsPorts,
15
+ rdbgaFromCmdline,
16
+ rdbgaFromRunningJvm,
17
+ readRdbgaCookies,
18
+ repairRdbgaCookie,
19
+ workbenchTargets,
20
+ } from '../lib/browser.js'
21
+ import { rdbgaValue } from '../lib/yms.js'
22
+
23
+ // Generated by the same code that writes the cookie, not pasted: if the format ever
24
+ // changes, this fixture moves with it instead of drifting away from production.
25
+ const MODULE = 'c-ass-kk-hans'
26
+ const WORKBENCH_HOST = 'srmdev.hanslaser.com'
27
+ const EXPECTED = rdbgaValue({ module: MODULE, context: '/', serverBindAddress: '172.20.63.131', serverPort: 31010 })
28
+ const HOSTS = hostVariants(WORKBENCH_HOST)
29
+
30
+ /** Fake WebSocket: lets a test script the handshake and every command. */
31
+ function fakeSocket(script) {
32
+ return class FakeWebSocket {
33
+ constructor(url) {
34
+ this.url = url
35
+ this.sent = []
36
+ setTimeout(() => { this.onopen?.() }, 0)
37
+ }
38
+
39
+ send(raw) {
40
+ const message = JSON.parse(raw)
41
+ this.sent.push(message)
42
+ const reply = script(message, this)
43
+ if (reply === null) return
44
+ setTimeout(() => { this.onmessage?.({ data: JSON.stringify({ id: message.id, ...reply }) }) }, 0)
45
+ }
46
+
47
+ close() { this.closed = true; this.onclose?.() }
48
+ }
49
+ }
50
+
51
+ test('the rdbga value is read out of a JVM command line, and absent lines stay empty', () => {
52
+ assert.equal(rdbgaFromCmdline(`java -Drdbga=${EXPECTED} -Dserver.port=31010 -cp x.jar Main`), EXPECTED)
53
+ assert.equal(rdbgaFromCmdline('java -Dserver.port=31010 -cp x.jar Main'), '')
54
+ assert.equal(rdbgaFromCmdline(''), '')
55
+ })
56
+
57
+ test('host variants cover the domain-cookie spelling, and domain matching is suffix-safe', () => {
58
+ assert.deepEqual(hostVariants('srmdev.hanslaser.com'), ['srmdev.hanslaser.com', '.srmdev.hanslaser.com'])
59
+ assert.deepEqual(hostVariants('.srmdev.hanslaser.com'), ['.srmdev.hanslaser.com', 'srmdev.hanslaser.com'])
60
+ assert.deepEqual(hostVariants(''), [])
61
+ assert.equal(domainMatches('.srmdev.hanslaser.com', HOSTS), true)
62
+ assert.equal(domainMatches('srmdev.hanslaser.com', HOSTS), true)
63
+ assert.equal(domainMatches('evil-srmdev.hanslaser.com', HOSTS), false)
64
+ assert.equal(domainMatches('other.com', HOSTS), false)
65
+ assert.equal(domainMatches('anything.com', []), true)
66
+ })
67
+
68
+ test('a value is only written when it decodes to "module,/context@ipv4:port"', () => {
69
+ assert.equal(isPlausibleRdbga(EXPECTED), true)
70
+ assert.equal(isPlausibleRdbga(''), false)
71
+ assert.equal(isPlausibleRdbga('null,/@null:31010'), false) // not base64
72
+ assert.equal(isPlausibleRdbga(Buffer.from('null,/@null:31010').toString('base64')), false)
73
+ assert.equal(isPlausibleRdbga(Buffer.from('c-ass-kk-hans,/@172.20.63.131:31010').toString('base64')), true)
74
+ assert.equal(isPlausibleRdbga('not base64 !!'), false)
75
+ })
76
+
77
+ test('an empty host-only rdbga is removed: it shadows the correct value', () => {
78
+ const verdict = classifyRdbgaCookies([
79
+ { name: RDBGA_NAME, value: '', domain: 'srmdev.hanslaser.com', path: '/' },
80
+ { name: RDBGA_NAME, value: EXPECTED, domain: '.srmdev.hanslaser.com', path: '/' },
81
+ ], EXPECTED)
82
+ assert.equal(verdict.keep.length, 1)
83
+ assert.equal(verdict.keep[0].domain, '.srmdev.hanslaser.com')
84
+ assert.equal(verdict.remove.length, 1)
85
+ assert.equal(verdict.remove[0].value, '')
86
+ })
87
+
88
+ test('only one rdbga survives: duplicates are removed even when the value matches', () => {
89
+ const verdict = classifyRdbgaCookies([
90
+ { name: RDBGA_NAME, value: EXPECTED, domain: '.srmdev.hanslaser.com', path: '/' },
91
+ { name: RDBGA_NAME, value: EXPECTED, domain: 'srmdev.hanslaser.com', path: '/' },
92
+ { name: 'JSESSIONID', value: 'abc', domain: '.hanslaser.com', path: '/' },
93
+ ], EXPECTED)
94
+ assert.equal(verdict.keep.length, 1)
95
+ assert.equal(verdict.remove.length, 1)
96
+ assert.ok(verdict.remove.every((cookie) => cookie.name === RDBGA_NAME))
97
+ })
98
+
99
+ test('a stale value is removed as well, and another domain is left alone', () => {
100
+ const verdict = classifyRdbgaCookies([
101
+ { name: RDBGA_NAME, value: 'b2xkLXN0YWxl', domain: 'srmdev.hanslaser.com', path: '/' },
102
+ { name: RDBGA_NAME, value: 'x', domain: 'elsewhere.com', path: '/' },
103
+ ], EXPECTED, HOSTS)
104
+ assert.equal(verdict.keep.length, 0)
105
+ assert.equal(verdict.remove.length, 1)
106
+ assert.equal(verdict.remove[0].domain, 'srmdev.hanslaser.com')
107
+ })
108
+
109
+ test('the devtools probe takes the first port that answers', async () => {
110
+ const seen = []
111
+ const fetchImpl = async (url) => {
112
+ seen.push(url)
113
+ if (url.includes(':9222/')) throw new Error('ECONNREFUSED')
114
+ return { ok: true, json: async () => ({ Browser: 'Chrome/151' }) }
115
+ }
116
+ assert.deepEqual(await probeDevtoolsPorts([9222, 9223], { fetchImpl }), { port: 9223, browser: 'Chrome/151' })
117
+ assert.equal(seen.length, 2)
118
+ })
119
+
120
+ test('no port answering is reported as port 0, not as an error', async () => {
121
+ const fetchImpl = async () => { throw new Error('ECONNREFUSED') }
122
+ assert.deepEqual(await probeDevtoolsPorts([9222], { fetchImpl }), { port: 0, browser: '' })
123
+ })
124
+
125
+ test('a hanging DevTools endpoint is cut off by the deadline', async () => {
126
+ const fetchImpl = () => new Promise(() => {})
127
+ const started = Date.now()
128
+ const found = await probeDevtoolsPorts([9222], { fetchImpl, timeout: 50 })
129
+ assert.deepEqual(found, { port: 0, browser: '' })
130
+ assert.ok(Date.now() - started < 1000)
131
+ })
132
+
133
+ test('only workbench pages are treated as targets, and a broken listing is reported', async () => {
134
+ const fetchImpl = async () => ({
135
+ ok: true,
136
+ json: async () => ([
137
+ { type: 'page', url: 'https://srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://a' },
138
+ { type: 'page', url: 'https://example.com/', webSocketDebuggerUrl: 'ws://b' },
139
+ { type: 'service_worker', url: 'https://srmdev.hanslaser.com/sw.js', webSocketDebuggerUrl: 'ws://c' },
140
+ ]),
141
+ })
142
+ const listed = await workbenchTargets(9222, { fetchImpl, hosts: HOSTS })
143
+ assert.equal(listed.ok, true)
144
+ assert.equal(listed.targets.length, 1)
145
+ assert.equal(listed.targets[0].webSocketDebuggerUrl, 'ws://a')
146
+ const broken = await workbenchTargets(9222, { fetchImpl: async () => { throw new Error('down') } })
147
+ assert.equal(broken.ok, false)
148
+ assert.match(broken.error, /页签/)
149
+ })
150
+
151
+ test('repair deletes the empty cookie and sets the expected one', async () => {
152
+ const calls = []
153
+ const cookies = [
154
+ { name: RDBGA_NAME, value: '', domain: 'srmdev.hanslaser.com', path: '/' },
155
+ { name: 'JSESSIONID', value: 'abc', domain: '.hanslaser.com', path: '/' },
156
+ ]
157
+ const fetchImpl = async () => ({
158
+ ok: true,
159
+ json: async () => ([{ type: 'page', url: 'https://srmdev.hanslaser.com/home', webSocketDebuggerUrl: 'ws://a' }]),
160
+ })
161
+ const connect = async () => ({
162
+ send: async (method, params) => {
163
+ calls.push([method, params])
164
+ if (method === 'Network.getCookies') return { cookies }
165
+ return {}
166
+ },
167
+ close: () => { calls.push(['close']) },
168
+ })
169
+ const result = await repairRdbgaCookie({ port: 9222, expected: EXPECTED, fetchImpl, connect })
170
+ assert.deepEqual(
171
+ { ok: result.ok, tabs: result.tabs, removed: result.removed, wrote: result.wrote, dryRun: result.dryRun },
172
+ { ok: true, tabs: 1, removed: 1, wrote: 1, dryRun: false },
173
+ )
174
+ assert.equal(calls[0][0], 'Network.enable')
175
+ const deletes = calls.filter(([method]) => method === 'Network.deleteCookies')
176
+ assert.equal(deletes.length, 1)
177
+ assert.equal(deletes[0][1].domain, 'srmdev.hanslaser.com')
178
+ const sets = calls.filter(([method]) => method === 'Network.setCookie')
179
+ assert.equal(sets.length, 1)
180
+ assert.equal(sets[0][1].value, EXPECTED)
181
+ assert.equal(sets[0][1].url, 'https://srmdev.hanslaser.com/')
182
+ })
183
+
184
+ test('repair deletes with the cookie own domain/path, not the tab host', async () => {
185
+ const calls = []
186
+ const fetchImpl = async () => ({
187
+ ok: true,
188
+ json: async () => ([{ type: 'page', url: 'https://srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://a' }]),
189
+ })
190
+ const connect = async () => ({
191
+ send: async (method, params) => {
192
+ calls.push([method, params])
193
+ if (method === 'Network.getCookies') {
194
+ return { cookies: [{ name: RDBGA_NAME, value: '', domain: '.srmdev.hanslaser.com', path: '/foo' }] }
195
+ }
196
+ return {}
197
+ },
198
+ close: () => {},
199
+ })
200
+ const result = await repairRdbgaCookie({ port: 9222, expected: EXPECTED, fetchImpl, connect })
201
+ assert.equal(result.ok, true)
202
+ const [method, params] = calls.find(([name]) => name === 'Network.deleteCookies')
203
+ assert.equal(method, 'Network.deleteCookies')
204
+ assert.equal(params.domain, '.srmdev.hanslaser.com')
205
+ assert.equal(params.path, '/foo')
206
+ })
207
+
208
+ test('a tab that cannot be reached is a reported failure, not a silent success', async () => {
209
+ const fetchImpl = async () => ({
210
+ ok: true,
211
+ json: async () => ([
212
+ { type: 'page', url: 'https://srmdev.hanslaser.com/a', webSocketDebuggerUrl: 'ws://a' },
213
+ { type: 'page', url: 'https://srmdev.hanslaser.com/b', webSocketDebuggerUrl: 'ws://b' },
214
+ ]),
215
+ })
216
+ const connect = async (wsUrl) => {
217
+ if (wsUrl === 'ws://b') throw new Error('connection closed')
218
+ return { send: async () => ({ cookies: [] }), close: () => {} }
219
+ }
220
+ const result = await repairRdbgaCookie({ port: 9222, expected: EXPECTED, fetchImpl, connect })
221
+ assert.equal(result.ok, false)
222
+ assert.equal(result.tabs, 1)
223
+ assert.equal(result.failed.length, 1)
224
+ assert.match(result.error, /部分页签失败(成功 1 个,失败 1 个)/)
225
+ assert.match(result.error, /srmdev\.hanslaser\.com\/b: connection closed/)
226
+ })
227
+
228
+ test('classify and describe agree when the cookie value is not a string', () => {
229
+ const cookies = [{ name: RDBGA_NAME, value: '123', domain: `srmdev.hanslaser.com`, path: '/' }]
230
+ const verdict = classifyRdbgaCookies(cookies, 123, HOSTS)
231
+ assert.equal(verdict.keep.length, 1)
232
+ assert.equal(verdict.remove.length, 0)
233
+ assert.match(describeRdbgaCookies(cookies, 123, HOSTS), /→ 正确/)
234
+ })
235
+
236
+ test('repair leaves a correct cookie alone and never sets a duplicate', async () => {
237
+ const calls = []
238
+ const fetchImpl = async () => ({
239
+ ok: true,
240
+ json: async () => ([{ type: 'page', url: 'https://srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://a' }]),
241
+ })
242
+ const connect = async () => ({
243
+ send: async (method, params) => {
244
+ calls.push([method, params])
245
+ if (method === 'Network.getCookies') {
246
+ return { cookies: [{ name: RDBGA_NAME, value: EXPECTED, domain: '.srmdev.hanslaser.com', path: '/' }] }
247
+ }
248
+ return {}
249
+ },
250
+ close: () => {},
251
+ })
252
+ const result = await repairRdbgaCookie({ port: 9222, expected: EXPECTED, fetchImpl, connect })
253
+ assert.equal(result.removed, 0)
254
+ assert.equal(result.wrote, 0)
255
+ assert.equal(calls.filter(([method]) => method === 'Network.setCookie').length, 0)
256
+ })
257
+
258
+ test('repair refuses to write a value it cannot verify', async () => {
259
+ const calls = []
260
+ const result = await repairRdbgaCookie({
261
+ port: 9222,
262
+ expected: Buffer.from('null,/@null:31010').toString('base64'),
263
+ fetchImpl: async () => { throw new Error('must not be called') },
264
+ connect: async () => ({ send: async () => { calls.push('send') }, close: () => {} }),
265
+ })
266
+ assert.equal(result.ok, false)
267
+ assert.match(result.error, /拒绝写入/)
268
+ assert.equal(calls.length, 0)
269
+ })
270
+
271
+ test('no workbench tab is a reported miss, not a crash', async () => {
272
+ const fetchImpl = async () => ({ ok: true, json: async () => ([]) })
273
+ const result = await repairRdbgaCookie({ port: 9222, expected: EXPECTED, fetchImpl, connect: async () => { throw new Error('never') } })
274
+ assert.equal(result.ok, false)
275
+ assert.match(result.error, /工作台/)
276
+ })
277
+
278
+ test('a CDP error rejects the command instead of looking like success', async () => {
279
+ const FakeWebSocket = fakeSocket((message) => ({ error: { code: -32000, message: `no way: ${message.method}` } }))
280
+ const cdp = await connectCdp('ws://fake', { WebSocketImpl: FakeWebSocket, timeout: 500 })
281
+ await assert.rejects(() => cdp.send('Network.setCookie', {}), (error) => error instanceof CdpError && /no way/.test(error.message))
282
+ cdp.close()
283
+ })
284
+
285
+ test('a browser that stops answering cannot hang the caller', async () => {
286
+ const FakeWebSocket = fakeSocket(() => null) // accept every command, answer none
287
+ const cdp = await connectCdp('ws://fake', { WebSocketImpl: FakeWebSocket, timeout: 60 })
288
+ const started = Date.now()
289
+ await assert.rejects(() => cdp.send('Network.getCookies'), /超时/)
290
+ assert.ok(Date.now() - started < 1000)
291
+ cdp.close()
292
+ })
293
+
294
+ test('closing the socket fails the commands still in flight', async () => {
295
+ // Whatever the browser was doing, a socket that goes away must settle the command
296
+ // right then — waiting for the deadline would report a misleading timeout.
297
+ const FakeWebSocket = fakeSocket((message, socket) => {
298
+ setTimeout(() => { socket.onclose?.() }, 10)
299
+ return null
300
+ })
301
+ const cdp = await connectCdp('ws://fake', { WebSocketImpl: FakeWebSocket, timeout: 5000 })
302
+ await assert.rejects(() => cdp.send('Network.getCookies'), /已关闭/)
303
+ cdp.close()
304
+ })
305
+
306
+ test('cookies are read for every tab, without a URL filter', async () => {
307
+ const seen = []
308
+ const connect = async () => ({
309
+ send: async (method, params) => { seen.push([method, params]); return { cookies: [{ name: RDBGA_NAME, value: EXPECTED, domain: '.srmdev.hanslaser.com', path: '/' }] } },
310
+ close: () => {},
311
+ })
312
+ const cookies = await cookiesOfTargets([{ url: 'https://a/', webSocketDebuggerUrl: 'ws://a' }, { url: 'https://b/', webSocketDebuggerUrl: 'ws://b' }], { connect })
313
+ assert.equal(cookies.length, 2)
314
+ const gets = seen.filter(([method]) => method === 'Network.getCookies')
315
+ assert.equal(gets.length, 2)
316
+ assert.equal(gets[0][1], undefined) // no `urls`: a path-scoped rdbga stays visible
317
+ })
318
+
319
+ test('read reports the tab listing error instead of an empty cookie list', async () => {
320
+ const read = await readRdbgaCookies({ port: 9222, fetchImpl: async () => { throw new Error('down') } })
321
+ assert.equal(read.ok, false)
322
+ assert.match(read.error, /页签/)
323
+ })
324
+
325
+ test('status text names the empty cookie as the problem', () => {
326
+ const text = describeRdbgaCookies([
327
+ { name: RDBGA_NAME, value: '', domain: 'srmdev.hanslaser.com', path: '/' },
328
+ { name: RDBGA_NAME, value: EXPECTED, domain: '.srmdev.hanslaser.com', path: '/' },
329
+ ], EXPECTED)
330
+ assert.match(text, /空值/)
331
+ assert.match(text, /正确/)
332
+ assert.match(text, /需要清理 1 个/)
333
+ })
334
+
335
+ test('workbench targets are matched by exact host, not by substring', async () => {
336
+ // A URL that merely *contains* the workbench host (trailing ".evil.com" or a query
337
+ // string) is not the workbench: writing there would silently target another site.
338
+ const fetchImpl = async () => ({
339
+ ok: true,
340
+ json: async () => ([
341
+ { type: 'page', url: 'https://srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://ok' },
342
+ { type: 'page', url: 'https://SRMDEV.Hanslaser.com/home', webSocketDebuggerUrl: 'ws://ok2' },
343
+ { type: 'page', url: 'https://srmdev.hanslaser.com.evil.com/', webSocketDebuggerUrl: 'ws://evil' },
344
+ { type: 'page', url: 'https://evil.com/?next=https://srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://query' },
345
+ { type: 'page', url: 'https://sub.srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://sub' },
346
+ { type: 'page', url: 'https://srmdev.hanslaser.com/alive', webSocketDebuggerUrl: null },
347
+ { type: 'page', url: 'https://srmdev.hanslaser.com/no-url' },
348
+ ]),
349
+ })
350
+ const listed = await workbenchTargets(9222, { fetchImpl, hosts: HOSTS })
351
+ assert.equal(listed.ok, true)
352
+ assert.deepEqual(listed.targets.map((target) => target.webSocketDebuggerUrl), ['ws://ok', 'ws://ok2'])
353
+ })
354
+
355
+ test('a caller-provided target list is reused instead of listing the tabs again', async () => {
356
+ const fetched = []
357
+ const fetchImpl = async (url) => { fetched.push(url); throw new Error('must not be called') }
358
+ const targets = [{ url: 'https://srmdev.hanslaser.com/', webSocketDebuggerUrl: 'ws://a' }]
359
+ const cookies = [{ name: RDBGA_NAME, value: EXPECTED, domain: `.${WORKBENCH_HOST}`, path: '/' }]
360
+ const connect = async () => ({
361
+ send: async (method) => (method === 'Network.getCookies' ? { cookies } : {}),
362
+ close: () => {},
363
+ })
364
+ const read = await readRdbgaCookies({ port: 9222, targets, fetchImpl, connect, hosts: HOSTS })
365
+ assert.deepEqual({ ok: read.ok, cookies: read.cookies.length }, { ok: true, cookies: 1 })
366
+ const repaired = await repairRdbgaCookie({ port: 9222, targets, expected: EXPECTED, fetchImpl, connect, hosts: HOSTS })
367
+ assert.deepEqual({ ok: repaired.ok, wrote: repaired.wrote }, { ok: true, wrote: 0 })
368
+ assert.equal(fetched.length, 0)
369
+ })
370
+
371
+ test('the JVM query always resolves to a report instead of throwing', async () => {
372
+ const live = await rdbgaFromRunningJvm()
373
+ assert.equal(typeof live.ok, 'boolean')
374
+ assert.ok(Array.isArray(live.values))
375
+ if (live.ok === false) assert.match(live.error, /JVM|java/)
376
+ })
377
+
378
+ test('dry-run reports the plan without deleting or writing any cookie', async () => {
379
+ const calls = []
380
+ const targets = [{ url: `https://${WORKBENCH_HOST}/`, webSocketDebuggerUrl: 'ws://a' }]
381
+ const connectFor = (cookies) => async () => ({
382
+ send: async (method) => { calls.push(method); return method === 'Network.getCookies' ? { cookies } : {} },
383
+ close: () => {},
384
+ })
385
+ const emptyAndCorrect = [
386
+ { name: RDBGA_NAME, value: '', domain: WORKBENCH_HOST, path: '/' },
387
+ { name: RDBGA_NAME, value: EXPECTED, domain: `.${WORKBENCH_HOST}`, path: '/' },
388
+ ]
389
+ const plan = await repairRdbgaCookie({
390
+ port: 9222, targets, expected: EXPECTED, connect: connectFor(emptyAndCorrect), dryRun: true,
391
+ })
392
+ assert.deepEqual(
393
+ { ok: plan.ok, tabs: plan.tabs, removed: plan.removed, wrote: plan.wrote, dryRun: plan.dryRun },
394
+ { ok: true, tabs: 1, removed: 1, wrote: 0, dryRun: true },
395
+ )
396
+ assert.ok(!calls.includes('Network.deleteCookies'))
397
+ assert.ok(!calls.includes('Network.setCookie'))
398
+
399
+ const onlyEmpty = await repairRdbgaCookie({
400
+ port: 9222,
401
+ targets,
402
+ expected: EXPECTED,
403
+ connect: connectFor([{ name: RDBGA_NAME, value: '', domain: WORKBENCH_HOST, path: '/' }]),
404
+ dryRun: true,
405
+ })
406
+ assert.deepEqual({ removed: onlyEmpty.removed, wrote: onlyEmpty.wrote }, { removed: 1, wrote: 1 })
407
+ assert.ok(!calls.includes('Network.deleteCookies'))
408
+ assert.ok(!calls.includes('Network.setCookie'))
409
+ })
410
+
411
+ test('status compares as strings and tells same-prefix cookies apart', () => {
412
+ // The old text compared with === (a numeric cookie would look wrong) and only showed
413
+ // the first 12 characters, so two values sharing the prefix looked identical.
414
+ const near = Buffer.from(`${MODULE},/@172.20.63.131:31011`).toString('base64')
415
+ assert.notEqual(EXPECTED.slice(-6), near.slice(-6))
416
+ const text = describeRdbgaCookies([
417
+ { name: RDBGA_NAME, value: near, domain: `.${WORKBENCH_HOST}`, path: '/' },
418
+ { name: RDBGA_NAME, value: EXPECTED, domain: `.${WORKBENCH_HOST}`, path: '/' },
419
+ ], EXPECTED)
420
+ assert.match(text, /1\. /)
421
+ assert.match(text, /2\. /)
422
+ assert.match(text, /值不匹配/)
423
+ assert.ok(text.includes(near.slice(-6)))
424
+ assert.ok(text.includes(EXPECTED.slice(-6)))
425
+ assert.match(text, /需要清理 1 个/)
426
+
427
+ const numeric = describeRdbgaCookies(
428
+ [{ name: RDBGA_NAME, value: '123', domain: `.${WORKBENCH_HOST}`, path: '/' }],
429
+ 123,
430
+ )
431
+ assert.match(numeric, /正确/)
432
+ })
433
+
434
+ test('an unknown expectation is reported as unknown instead of "all wrong"', () => {
435
+ const text = describeRdbgaCookies(
436
+ [{ name: RDBGA_NAME, value: EXPECTED, domain: `.${WORKBENCH_HOST}`, path: '/' }],
437
+ '',
438
+ )
439
+ assert.match(text, /无法确定/)
440
+ assert.match(text, /值未知/)
441
+ assert.doesNotMatch(text, /需要清理/)
442
+ })
@@ -0,0 +1,40 @@
1
+ // `yds stop` must not tear the tunnel down under a YMS it could not stop, and its
2
+ // summary must say how much of the shared rule table actually changed (review
3
+ // D-P1-2 / D-P2-7).
4
+ import assert from 'node:assert/strict'
5
+ import test from 'node:test'
6
+
7
+ import { stopAbortReason, withdrawnText } from '../lib/cli.js'
8
+
9
+ test('stop aborts when a live YMS pid could not be stopped', () => {
10
+ const reason = stopAbortReason({ stopped: false, pid: 4242, reason: '停止失败:Access is denied(已保留 pidfile)' })
11
+ assert.match(reason, /4242/)
12
+ assert.match(reason, /已中止/)
13
+ assert.match(reason, /--force/)
14
+ assert.match(reason, /Access is denied/, 'the original failure is surfaced')
15
+ })
16
+
17
+ test('stop continues when nothing is running or the stop succeeded', () => {
18
+ assert.equal(stopAbortReason({ stopped: false, reason: '未在运行' }), '', 'no pid => nothing to protect')
19
+ assert.equal(stopAbortReason({ stopped: false, pid: 0 }), '')
20
+ assert.equal(stopAbortReason({ stopped: true, pid: 4242 }), '')
21
+ })
22
+
23
+ test('--force overrides the guard', () => {
24
+ assert.equal(stopAbortReason({ stopped: false, pid: 4242, reason: 'unknown' }, { force: true }), '')
25
+ })
26
+
27
+ test('the summary reports what was withdrawn instead of a blanket claim', () => {
28
+ assert.match(withdrawnText({ rules: 2, hosts: 3 }), /2 条规则/)
29
+ assert.match(withdrawnText({ rules: 2, hosts: 3 }), /3 个 host/)
30
+ assert.match(withdrawnText({ rules: 2, hosts: 3 }), /全局一张表,不区分 workspace/)
31
+ assert.doesNotMatch(withdrawnText({ rules: 2, hosts: 3 }), /其它 workspace 的规则保留/)
32
+ })
33
+
34
+ test('a stop that changed nothing says so', () => {
35
+ for (const withdrawn of [{ rules: 0, hosts: 0 }, undefined, {}]) {
36
+ const text = withdrawnText(withdrawn)
37
+ assert.match(text, /共享规则表里没有 yds 规则(未改动)/)
38
+ assert.doesNotMatch(text, /已撤掉/)
39
+ }
40
+ })