@hyzyn/dsh-docker 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +31 -5
- package/README.md +41 -13
- package/client-src/docker.css +13 -13
- package/client-src/index.js +466 -246
- package/client-src/log-buffer.js +205 -0
- package/client-src/log-stream.js +143 -0
- package/client.js +37 -36
- package/lib/docker.d.ts +18 -3
- package/lib/docker.js +54 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.js +154 -19
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/scripts/client-smoke.mjs +261 -0
- package/scripts/log-perf.mjs +206 -0
- package/scripts/route-smoke.mjs +21 -0
- package/scripts/smoke.mjs +59 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyzyn/dsh-docker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "DSH Web GUI 的 Docker 容器面板插件:本机或 SSH 主机上的容器列表 / 启停删 / 日志 / 统计 / 镜像 / 一次性 exec,默认只读,配套只读优先的 agent 工具。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"patch": "./cordis.patch.yml"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
|
-
"dsh": ">=0.1.7-rc.
|
|
39
|
+
"dsh": ">=0.1.7-rc.2"
|
|
40
40
|
},
|
|
41
41
|
"client": {
|
|
42
42
|
"platform": "web"
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"ssh2": "^1.17.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@deepseek-ai/dsh": "^0.1.7-rc.
|
|
52
|
-
"@deepseek-ai/dsh-tools": "^0.1.7-rc.
|
|
51
|
+
"@deepseek-ai/dsh": "^0.1.7-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
|
|
91
91
|
"typecheck": "tsc --noEmit && node ../../scripts/client-lint.mjs",
|
|
92
92
|
"smoke": "node scripts/smoke.mjs && node scripts/route-smoke.mjs && node scripts/client-smoke.mjs",
|
|
93
|
+
"perf:logs": "node scripts/log-perf.mjs",
|
|
93
94
|
"lint:client": "node ../../scripts/client-lint.mjs"
|
|
94
95
|
}
|
|
95
96
|
}
|
package/scripts/client-smoke.mjs
CHANGED
|
@@ -752,6 +752,39 @@ await test('日志级别档位:共用一份定义,含 INFO+(真实日志
|
|
|
752
752
|
}
|
|
753
753
|
})
|
|
754
754
|
|
|
755
|
+
await test('日志级别过滤:`%5p` 右填充的 `[INFO ]` / `[WARN ]` 必须认出来(否则门槛整个失效)', () => {
|
|
756
|
+
const agg = aggLogsApi()
|
|
757
|
+
/*
|
|
758
|
+
* 回归现场(用户截图):Logback / Spring 的 `%5p` 把级别右填充到 5 字符,
|
|
759
|
+
* 于是 INFO/WARN/DEBUG 写成 `[INFO ]`,而 ERROR/FATAL 恰好 5 字符不带空格。
|
|
760
|
+
* 旧正则只认 `[INFO]`,这些行被判成「无级别」——looks 无害,实则致命:
|
|
761
|
+
* filterByLevelCore 对未知级别走「不误杀」分支(rank=null 一律保留),
|
|
762
|
+
* 所以选了 WARN+ 仍会显示一屏 INFO,过滤像没生效。
|
|
763
|
+
*/
|
|
764
|
+
assert.equal(agg.levelName('[INFO ] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'INFO')
|
|
765
|
+
assert.equal(agg.levelName('[WARN ] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'WARN')
|
|
766
|
+
assert.equal(agg.levelName('[DEBUG] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'DEBUG')
|
|
767
|
+
assert.equal(agg.levelName('[ERROR] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'ERROR')
|
|
768
|
+
// 既有形态不能被这次放宽改坏:未填充 / 前导空格 / Spring 竖线 / 无级别
|
|
769
|
+
assert.equal(agg.levelName('[INFO] [2026-09-09 18:05:52] unpadded'), 'INFO')
|
|
770
|
+
assert.equal(agg.levelName('[ INFO] leading pad'), 'INFO')
|
|
771
|
+
assert.equal(agg.levelName('16:11:34,150 |INFO in Spring'), 'INFO')
|
|
772
|
+
assert.equal(agg.levelName('没有待处理的考试,定时任务结束'), null)
|
|
773
|
+
|
|
774
|
+
// 端到端:WARN+ 滤掉填充过的 INFO 行,且紧邻的续行继承语义不受影响
|
|
775
|
+
const lines = [
|
|
776
|
+
'[INFO ] [2026-09-25 11:31:00] [scheduling-1] ==> 没有待处理的考试,定时任务结束',
|
|
777
|
+
'[WARN ] [2026-09-25 11:31:05] [scheduling-1] ==> 磁盘将满',
|
|
778
|
+
' 续行继承上一行的级别',
|
|
779
|
+
]
|
|
780
|
+
assert.deepEqual(agg.filterLinesByLevel(lines, 3), [lines[1], lines[2]], 'WARN+ 必须滤掉填充过的 INFO')
|
|
781
|
+
assert.deepEqual(agg.filterLinesByLevel(lines, 2), lines, 'INFO+ 三行都该留下')
|
|
782
|
+
assert.deepEqual(agg.filterLinesByLevel(lines, 4), [], 'ERROR+ 该清空(续行继承 WARN)')
|
|
783
|
+
// 行对象适配器与纯文本适配器必须给出同一结论
|
|
784
|
+
const rows = lines.map((text) => ({ text }))
|
|
785
|
+
assert.deepEqual(agg.filterByLevel(rows, 3).map((row) => row.text), agg.filterLinesByLevel(lines, 3))
|
|
786
|
+
})
|
|
787
|
+
|
|
755
788
|
await test('日志导出对齐:两种格式共用构建器,只有标题与作用域不同', () => {
|
|
756
789
|
const pick = aggLogsApi()
|
|
757
790
|
const rows = [{ service: 'web', ts: 1755000000000, text: 'ERROR boom' }]
|
|
@@ -769,6 +802,34 @@ await test('日志导出对齐:两种格式共用构建器,只有标题与
|
|
|
769
802
|
assert.match(many, /容器(2):web、db/)
|
|
770
803
|
})
|
|
771
804
|
|
|
805
|
+
await test('日志导出:两个格式合并成一个菜单按钮(D137 —— 窄面板下不再各占一格)', () => {
|
|
806
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
807
|
+
const agg = exports_.__aggLogs
|
|
808
|
+
const decoded = decodeBundle(code)
|
|
809
|
+
|
|
810
|
+
// 「一份定义」不是口号:按钮文案在产物里出现两次 = 两个视图又各写了一套
|
|
811
|
+
// (级别档位、过滤内核当初就是这么漂的)
|
|
812
|
+
assert.equal(typeof agg.EXPORT_LABEL, 'string', '缺少 __aggLogs.EXPORT_LABEL 测试缝')
|
|
813
|
+
assert.ok(decoded.includes(agg.EXPORT_LABEL), '产物里没有导出按钮文案')
|
|
814
|
+
assert.equal(
|
|
815
|
+
decoded.split(agg.EXPORT_LABEL).length - 1,
|
|
816
|
+
1,
|
|
817
|
+
agg.EXPORT_LABEL + ' 在产物里出现了多次(视图各写了一套按钮?)',
|
|
818
|
+
)
|
|
819
|
+
// 两个视图的按钮都必须是「菜单触发器」——否则「这是个菜单」对读屏用户不可知
|
|
820
|
+
assert.equal(decoded.split('aria-haspopup').length - 1, 2, '两个视图的导出按钮都要标 aria-haspopup')
|
|
821
|
+
// 旧的「一个格式一个按钮」不许回来(正是它把 .md 挤到第二行)
|
|
822
|
+
assert.ok(!decoded.includes('导出当前显示内容为 .log'), '旧的单格式按钮文案残留(又拆回两个按钮了?)')
|
|
823
|
+
|
|
824
|
+
// 菜单里两个格式都在,且各自把正确的 format 交回导出函数
|
|
825
|
+
const picked = []
|
|
826
|
+
const items = agg.exportMenuItems((format) => picked.push(format))
|
|
827
|
+
assert.equal(items.length, 2, '导出菜单必须恰好两个格式')
|
|
828
|
+
assert.deepEqual(items.map((item) => item.label), ['⬇ .log', '⬇ .md'])
|
|
829
|
+
for (const item of items) item.onPick()
|
|
830
|
+
assert.deepEqual(picked, ['log', 'md'], '菜单项必须把对应的 format 传回导出函数')
|
|
831
|
+
})
|
|
832
|
+
|
|
772
833
|
await test('渲染期守卫:面板组件体直接跑一遍不能抛(TDZ 那类错误曾让面板整个空白)', () => {
|
|
773
834
|
const exports_ = registration.factory((spec) => SEED[spec])
|
|
774
835
|
assert.ok(exports_.__render !== undefined, '缺少 __render 测试缝')
|
|
@@ -776,6 +837,9 @@ await test('渲染期守卫:面板组件体直接跑一遍不能抛(TDZ 那
|
|
|
776
837
|
['DockerTabBody', {}],
|
|
777
838
|
['ContainerPanel', { carrier: 'tab', onClose: () => {}, initialTarget: '' }],
|
|
778
839
|
['ContainerPanel', { onClose: () => {}, initialTarget: '' }], // 模态 / docked 形态(carrier 缺省)
|
|
840
|
+
// 聚合日志视图(D137 也动了它的导出按钮):它的工具条与单容器那条共用同一份定义,
|
|
841
|
+
// 但只在「多选聚合 / compose 详情」下才挂载,上面两个入口都盖不到它
|
|
842
|
+
['ComposeLogs', { target: '目标1', targetLabel: '目标1', items: [{ id: 'a', name: 'web' }, { id: 'b', name: 'db' }] }],
|
|
779
843
|
]
|
|
780
844
|
for (const [name, props] of cases) {
|
|
781
845
|
const component = exports_.__render[name]
|
|
@@ -1831,6 +1895,203 @@ await test('总览:degraded 目标出提示(inspect 降级 / 候选超预算
|
|
|
1831
1895
|
assert.match(value.attentionNotice, /已降级/)
|
|
1832
1896
|
})
|
|
1833
1897
|
|
|
1898
|
+
/* ------------------------------------------------------------------ *
|
|
1899
|
+
* 日志缓冲(D63 根治):环形上限 / 残行分片 / 单调 id——突发灌入的内存护栏。
|
|
1900
|
+
* 真实 EventSource 时序进不了 Node 桩,但「缓冲吃多少、渲染信号发多少次」
|
|
1901
|
+
* 是纯逻辑,在这里钉死。
|
|
1902
|
+
* ------------------------------------------------------------------ */
|
|
1903
|
+
|
|
1904
|
+
await test('日志缓冲:burst 灌入上行数/字节双限与单调 id(D63 根治)', () => {
|
|
1905
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1906
|
+
const seam = exports_.__logBuffer
|
|
1907
|
+
assert.ok(seam !== undefined && typeof seam.create === 'function', '缺少 __logBuffer 测试缝')
|
|
1908
|
+
const { create, MAX_LINES, BYTE_LIMIT, PENDING_MAX, FLUSH_MS } = seam
|
|
1909
|
+
// 常量在场:缓冲行数/字节上限与合帧节奏(显示层不再另设截断)
|
|
1910
|
+
assert.equal(MAX_LINES, 5000)
|
|
1911
|
+
assert.equal(BYTE_LIMIT, 4 * 1024 * 1024)
|
|
1912
|
+
assert.equal(PENDING_MAX, 1024 * 1024)
|
|
1913
|
+
assert.equal(FLUSH_MS, 150)
|
|
1914
|
+
|
|
1915
|
+
// burst:3 万行一次灌入,行数上限收口;id 严格递增(渲染 key 稳定性的前提)
|
|
1916
|
+
const buffer = create({ maxLines: MAX_LINES, maxBytes: BYTE_LIMIT, maxPendingBytes: PENDING_MAX })
|
|
1917
|
+
const lines = Array.from({ length: 30_000 }, (_, index) => 'line-' + String(index))
|
|
1918
|
+
assert.equal(buffer.pushChunk(lines.join('\n') + '\n').appended, lines.length)
|
|
1919
|
+
assert.equal(buffer.count(), MAX_LINES)
|
|
1920
|
+
const snap = buffer.snapshot()
|
|
1921
|
+
assert.equal(snap[0].text, 'line-' + String(lines.length - MAX_LINES))
|
|
1922
|
+
for (let i = 1; i < snap.length; i++) assert.ok(snap[i].id > snap[i - 1].id, '行 id 必须单调递增')
|
|
1923
|
+
assert.equal(buffer.takeDropped(), true)
|
|
1924
|
+
assert.equal(buffer.takeDropped(), false)
|
|
1925
|
+
|
|
1926
|
+
// 大行:字节上限收口(旧行为只限行数,大行场景能吃掉数百 MB)
|
|
1927
|
+
const bigLine = 'x'.repeat(64 * 1024)
|
|
1928
|
+
const buffer2 = create({ maxLines: MAX_LINES, maxBytes: 1024 * 1024, maxPendingBytes: PENDING_MAX })
|
|
1929
|
+
for (let i = 0; i < 64; i++) buffer2.pushChunk(bigLine + '\n')
|
|
1930
|
+
const live = buffer2.snapshot().reduce((sum, entry) => sum + entry.bytes, 0)
|
|
1931
|
+
assert.ok(live <= 1024 * 1024, '存活字节必须 ≤ maxBytes,实际 ' + String(live))
|
|
1932
|
+
})
|
|
1933
|
+
|
|
1934
|
+
await test('日志流重连:首连带 tail、重连一律 tail=0(D133 不重放历史)', () => {
|
|
1935
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1936
|
+
const seam = exports_.__logStream
|
|
1937
|
+
assert.ok(seam !== undefined && typeof seam.subscribe === 'function', '缺少 __logStream 测试缝')
|
|
1938
|
+
assert.equal(seam.RECONNECT_BASE_MS, 1000)
|
|
1939
|
+
assert.equal(seam.RECONNECT_MAX_MS, 15000)
|
|
1940
|
+
// 规则本身:首连保持调用方给的 tail,重连恒为 0
|
|
1941
|
+
assert.equal(seam.reconnectTail(200, false), 200)
|
|
1942
|
+
assert.equal(seam.reconnectTail(200, true), 0)
|
|
1943
|
+
assert.equal(seam.reconnectTail(5000, true), 0)
|
|
1944
|
+
|
|
1945
|
+
// 用可注入的假 EventSource 真跑一遍订阅:首连 URL 带 200,断线重连后带 0
|
|
1946
|
+
const urls = []
|
|
1947
|
+
const instances = []
|
|
1948
|
+
class FakeEventSource {
|
|
1949
|
+
constructor(url) {
|
|
1950
|
+
this.url = url
|
|
1951
|
+
this.readyState = 0
|
|
1952
|
+
this.listeners = new Map()
|
|
1953
|
+
urls.push(url)
|
|
1954
|
+
instances.push(this)
|
|
1955
|
+
}
|
|
1956
|
+
addEventListener(name, fn) {
|
|
1957
|
+
const list = this.listeners.get(name) ?? []
|
|
1958
|
+
list.push(fn)
|
|
1959
|
+
this.listeners.set(name, list)
|
|
1960
|
+
}
|
|
1961
|
+
/** data 省略 = 连接层错误那种「没有 data 的普通 Event」。 */
|
|
1962
|
+
emit(name, data) {
|
|
1963
|
+
for (const fn of this.listeners.get(name) ?? []) fn(data === undefined ? {} : { data: JSON.stringify(data) })
|
|
1964
|
+
}
|
|
1965
|
+
open() {
|
|
1966
|
+
this.readyState = 1
|
|
1967
|
+
if (this.onopen) this.onopen()
|
|
1968
|
+
}
|
|
1969
|
+
close() {
|
|
1970
|
+
this.readyState = 2
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
const previous = globalThis.EventSource
|
|
1974
|
+
const previousSetTimeout = globalThis.setTimeout
|
|
1975
|
+
globalThis.EventSource = FakeEventSource
|
|
1976
|
+
// 退避定时器直接同步执行,免得用例等着真实 1s
|
|
1977
|
+
globalThis.setTimeout = (fn) => {
|
|
1978
|
+
fn()
|
|
1979
|
+
return 0
|
|
1980
|
+
}
|
|
1981
|
+
try {
|
|
1982
|
+
const lines = []
|
|
1983
|
+
const handle = seam.subscribe({
|
|
1984
|
+
buildUrl: (tail) => '/api/dsh-docker/logs/stream?tail=' + String(tail),
|
|
1985
|
+
tail: 200,
|
|
1986
|
+
onLine: (text) => lines.push(text),
|
|
1987
|
+
})
|
|
1988
|
+
assert.deepEqual(urls, ['/api/dsh-docker/logs/stream?tail=200'])
|
|
1989
|
+
instances[0].open()
|
|
1990
|
+
instances[0].emit('line', { d: 'first\n' })
|
|
1991
|
+
// 连接层错误(无 data 的 Event):触发手动重连,URL 必须换成 tail=0
|
|
1992
|
+
instances[0].emit('error')
|
|
1993
|
+
assert.deepEqual(urls, [
|
|
1994
|
+
'/api/dsh-docker/logs/stream?tail=200',
|
|
1995
|
+
'/api/dsh-docker/logs/stream?tail=0',
|
|
1996
|
+
])
|
|
1997
|
+
instances[1].open()
|
|
1998
|
+
instances[1].emit('line', { d: 'second\n' })
|
|
1999
|
+
assert.deepEqual(lines, ['first\n', 'second\n'])
|
|
2000
|
+
handle.close()
|
|
2001
|
+
} finally {
|
|
2002
|
+
globalThis.EventSource = previous
|
|
2003
|
+
globalThis.setTimeout = previousSetTimeout
|
|
2004
|
+
}
|
|
2005
|
+
})
|
|
2006
|
+
|
|
2007
|
+
await test('日志流重连:服务端 end{output-limit} 交给调用方决定重连,不静默当成正常结束', () => {
|
|
2008
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2009
|
+
const seam = exports_.__logStream
|
|
2010
|
+
const urls = []
|
|
2011
|
+
const instances = []
|
|
2012
|
+
class FakeEventSource {
|
|
2013
|
+
constructor(url) {
|
|
2014
|
+
this.url = url
|
|
2015
|
+
this.readyState = 0
|
|
2016
|
+
this.listeners = new Map()
|
|
2017
|
+
urls.push(url)
|
|
2018
|
+
instances.push(this)
|
|
2019
|
+
}
|
|
2020
|
+
addEventListener(name, fn) {
|
|
2021
|
+
const list = this.listeners.get(name) ?? []
|
|
2022
|
+
list.push(fn)
|
|
2023
|
+
this.listeners.set(name, list)
|
|
2024
|
+
}
|
|
2025
|
+
/** data 省略 = 连接层错误那种「没有 data 的普通 Event」。 */
|
|
2026
|
+
emit(name, data) {
|
|
2027
|
+
for (const fn of this.listeners.get(name) ?? []) fn(data === undefined ? {} : { data: JSON.stringify(data) })
|
|
2028
|
+
}
|
|
2029
|
+
open() {
|
|
2030
|
+
this.readyState = 1
|
|
2031
|
+
if (this.onopen) this.onopen()
|
|
2032
|
+
}
|
|
2033
|
+
close() {
|
|
2034
|
+
this.readyState = 2
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
const previous = globalThis.EventSource
|
|
2038
|
+
const previousSetTimeout = globalThis.setTimeout
|
|
2039
|
+
globalThis.EventSource = FakeEventSource
|
|
2040
|
+
globalThis.setTimeout = (fn) => {
|
|
2041
|
+
fn()
|
|
2042
|
+
return 0
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
const seen = []
|
|
2046
|
+
seam.subscribe({
|
|
2047
|
+
buildUrl: (tail) => '/logs/stream?tail=' + String(tail),
|
|
2048
|
+
tail: 50,
|
|
2049
|
+
onEnd: (payload, controls) => {
|
|
2050
|
+
seen.push(payload)
|
|
2051
|
+
if (payload && payload.reason === 'output-limit') controls.reconnect()
|
|
2052
|
+
},
|
|
2053
|
+
})
|
|
2054
|
+
instances[0].open()
|
|
2055
|
+
instances[0].emit('end', { reason: 'output-limit' })
|
|
2056
|
+
assert.equal(seen.length, 1)
|
|
2057
|
+
assert.equal(seen[0].reason, 'output-limit')
|
|
2058
|
+
// 溢出后重连:同样不许带历史(否则重复回填)
|
|
2059
|
+
assert.deepEqual(urls, ['/logs/stream?tail=50', '/logs/stream?tail=0'])
|
|
2060
|
+
} finally {
|
|
2061
|
+
globalThis.EventSource = previous
|
|
2062
|
+
globalThis.setTimeout = previousSetTimeout
|
|
2063
|
+
}
|
|
2064
|
+
})
|
|
2065
|
+
|
|
2066
|
+
await test('日志缓冲:无换行的超长输出被残行分片钉在有界内存内', () => {
|
|
2067
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2068
|
+
const { create, PENDING_MAX } = exports_.__logBuffer
|
|
2069
|
+
const buffer = create({ maxLines: 100, maxBytes: 1024 * 1024, maxPendingBytes: PENDING_MAX })
|
|
2070
|
+
// 模拟「永远不换行的输出」:32MB 连续灌入,残行每次到 1MB 强制落行
|
|
2071
|
+
for (let i = 0; i < 32; i++) buffer.pushChunk('y'.repeat(PENDING_MAX))
|
|
2072
|
+
const live = buffer.snapshot().reduce((sum, entry) => sum + entry.bytes, 0)
|
|
2073
|
+
assert.ok(live <= 1024 * 1024 + PENDING_MAX, '存活字节必须有界,实际 ' + String(live))
|
|
2074
|
+
assert.ok(buffer.pendingLength() <= PENDING_MAX)
|
|
2075
|
+
assert.ok(buffer.takeDropped(), '持续超限必须置 dropped')
|
|
2076
|
+
})
|
|
2077
|
+
|
|
2078
|
+
await test('日志缓冲:快照切分不把结尾换行算成一行(D136 —— --tail 201 必须显示 201 行)', () => {
|
|
2079
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2080
|
+
const { splitLines, create } = exports_.__logBuffer
|
|
2081
|
+
assert.equal(typeof splitLines, 'function', '缺少 __logBuffer.splitLines 测试缝')
|
|
2082
|
+
// 现场:docker logs --tail 201 → 201 行、每行以 \n 结尾。旧的 text.split('\n') 会切出 202 段。
|
|
2083
|
+
const text = Array.from({ length: 201 }, (_, i) => 'line ' + String(i)).join('\n') + '\n'
|
|
2084
|
+
assert.equal(splitLines(text).length, 201, '计数必须等于 --tail 的行数')
|
|
2085
|
+
assert.equal(splitLines(text).at(-1), 'line 200', '末尾不该多出一条空行')
|
|
2086
|
+
// 与 FOLLOW 缓冲的结论一致:同一份文本,两个视图的行数不许不同
|
|
2087
|
+
const buffer = create({ maxLines: 5000, maxBytes: 4 * 1024 * 1024 })
|
|
2088
|
+
buffer.pushChunk(text)
|
|
2089
|
+
assert.equal(buffer.snapshot().length, splitLines(text).length, '快照视图与 FOLLOW 视图必须同行数')
|
|
2090
|
+
// 真正以空行结尾的日志要留住那个空行(只剥终止符)
|
|
2091
|
+
assert.deepEqual(splitLines('a\n\n'), ['a', ''])
|
|
2092
|
+
assert.deepEqual(splitLines(''), [])
|
|
2093
|
+
})
|
|
2094
|
+
|
|
1834
2095
|
/* ------------------------------------------------------------------ *
|
|
1835
2096
|
* 结果
|
|
1836
2097
|
* ------------------------------------------------------------------ */
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 日志页性能门禁(D128 的浏览器侧护栏)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有它:日志页当初「网页直接崩溃」是个**性能**缺陷——单测能钉住缓冲上限与
|
|
6
|
+
* 合帧常量,但钉不住「5000 行首屏要多久 / 突发时事件循环卡多久」。这里用真 Chrome
|
|
7
|
+
* 量这两个数并断言预算,回归就再也回不来了。
|
|
8
|
+
*
|
|
9
|
+
* 依赖 tty 的预览夹具(vendor React + mock-host + 面板外壳):
|
|
10
|
+
* node ../tty/scripts/preview.mjs # 首次/夹具缺失时先生成
|
|
11
|
+
* 夹具或 Chrome 缺失时**跳过**(exit 0)——它是 npm script,不是 vitest 必跑项。
|
|
12
|
+
*
|
|
13
|
+
* node scripts/log-perf.mjs # 默认
|
|
14
|
+
* CHROME_PATH=/path/to/chrome node scripts/log-perf.mjs
|
|
15
|
+
*/
|
|
16
|
+
import http from 'node:http'
|
|
17
|
+
import { readFileSync, existsSync, copyFileSync, mkdirSync, statSync } from 'node:fs'
|
|
18
|
+
import { join, extname, resolve, dirname } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { Chrome } from '../../../scripts/chrome-cdp.mjs'
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const pkg = resolve(here, '..')
|
|
24
|
+
const repo = resolve(pkg, '../..')
|
|
25
|
+
const fixtureDir = join(repo, 'packages/tty/.preview')
|
|
26
|
+
|
|
27
|
+
/** 预算:宽松到不会因机器抖动误报,紧到能抓住「每 chunk 全量重渲染」那类回归。 */
|
|
28
|
+
const BUDGET = {
|
|
29
|
+
snapshotMs: 2500,
|
|
30
|
+
snapshotRows: 5000,
|
|
31
|
+
burstMs: 8000,
|
|
32
|
+
burstMaxLagMs: 400,
|
|
33
|
+
maxNodes: 60_000,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function skip(reason) {
|
|
37
|
+
console.log('[log-perf] 跳过:' + reason)
|
|
38
|
+
process.exit(0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const chromePath = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
42
|
+
if (!existsSync(chromePath)) skip('没找到 Chrome(可用 CHROME_PATH 指定)')
|
|
43
|
+
for (const file of ['harness.html', 'mock-host.js', 'harness.js', 'vendor/react.js', 'vendor/react-dom.js']) {
|
|
44
|
+
if (!existsSync(join(fixtureDir, file))) skip('预览夹具缺 ' + file + '(先跑 node packages/tty/scripts/preview.mjs)')
|
|
45
|
+
}
|
|
46
|
+
if (!existsSync(join(pkg, 'client.js'))) skip('client.js 还没构建(先跑 pnpm --filter @hyzyn/dsh-docker build)')
|
|
47
|
+
|
|
48
|
+
// 把当前构建的 docker client 换进夹具(夹具读 .preview/docker-client.js)
|
|
49
|
+
mkdirSync(fixtureDir, { recursive: true })
|
|
50
|
+
copyFileSync(join(pkg, 'client.js'), join(fixtureDir, 'docker-client.js'))
|
|
51
|
+
|
|
52
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' }
|
|
53
|
+
const server = http.createServer((req, res) => {
|
|
54
|
+
const path = decodeURIComponent(String(req.url).split('?')[0])
|
|
55
|
+
const file = join(fixtureDir, path === '/' ? 'harness.html' : path)
|
|
56
|
+
if (!file.startsWith(fixtureDir) || !existsSync(file) || statSync(file).isDirectory()) {
|
|
57
|
+
res.writeHead(404).end('not found')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' })
|
|
61
|
+
res.end(readFileSync(file))
|
|
62
|
+
})
|
|
63
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
64
|
+
const port = server.address().port
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* 页面里的量测:5000 行快照的首屏耗时 / DOM 规模,然后灌 400×50 行的 FOLLOW 突发,
|
|
68
|
+
* 记录事件循环最大延迟与收敛后的行数。整段与 .preview/log-perf.mjs 一致,只多出
|
|
69
|
+
* 「返回结构化数字」以便这里断言。
|
|
70
|
+
*/
|
|
71
|
+
const MEASURE = `(async () => {
|
|
72
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
73
|
+
const q = (s) => document.querySelector(s)
|
|
74
|
+
const qa = (s) => [...document.querySelectorAll(s)]
|
|
75
|
+
const waitFor = async (fn, timeout = 15000) => {
|
|
76
|
+
const t0 = performance.now()
|
|
77
|
+
for (;;) {
|
|
78
|
+
let v = null
|
|
79
|
+
try { v = fn() } catch { v = null }
|
|
80
|
+
if (v) return performance.now() - t0
|
|
81
|
+
if (performance.now() - t0 > timeout) throw new Error('waitFor 超时')
|
|
82
|
+
await sleep(25)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const out = { steps: [] }
|
|
86
|
+
try {
|
|
87
|
+
await waitFor(() => q('.dk_panel'))
|
|
88
|
+
await waitFor(() => qa('.dk_card').length > 0)
|
|
89
|
+
const LINE = '2026-09-24T12:00:00.000Z [INFO] request handled in 12ms path=/api/v1/orders/{id} trace=abc'
|
|
90
|
+
const BIG = Array.from({ length: 5000 }, (_, i) => LINE + ' seq=' + String(i)).join('\\n')
|
|
91
|
+
const realFetch = window.fetch.bind(window)
|
|
92
|
+
window.fetch = (url, init) => {
|
|
93
|
+
const target = typeof url === 'string' ? url : String(url && url.url ? url.url : url)
|
|
94
|
+
if (target.indexOf('/logs/stream') === -1 && target.indexOf('/logs') !== -1) {
|
|
95
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, logs: { id: 'app-web-1', text: BIG, truncated: false } }) })
|
|
96
|
+
}
|
|
97
|
+
return realFetch(url, init)
|
|
98
|
+
}
|
|
99
|
+
const card = qa('.dk_card')[0]
|
|
100
|
+
if (!card) throw new Error('没有容器卡片')
|
|
101
|
+
card.click()
|
|
102
|
+
await waitFor(() => q('.dk_tab'))
|
|
103
|
+
const logsTab = qa('.dk_tab').find((el) => el.textContent.trim() === '日志')
|
|
104
|
+
if (!logsTab) throw new Error('没有日志页签')
|
|
105
|
+
const t0 = performance.now()
|
|
106
|
+
logsTab.click()
|
|
107
|
+
await waitFor(() => qa('.dk_logLine').length >= 4000)
|
|
108
|
+
await sleep(150)
|
|
109
|
+
out.snapshotMs = Math.round(performance.now() - t0)
|
|
110
|
+
out.snapshotRows = qa('.dk_logLine').length
|
|
111
|
+
out.snapshotNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
|
|
112
|
+
|
|
113
|
+
let maxLag = 0
|
|
114
|
+
let last = performance.now()
|
|
115
|
+
const probe = setInterval(() => {
|
|
116
|
+
const now = performance.now()
|
|
117
|
+
maxLag = Math.max(maxLag, now - last - 16)
|
|
118
|
+
last = now
|
|
119
|
+
}, 16)
|
|
120
|
+
|
|
121
|
+
let emit = null
|
|
122
|
+
window.EventSource = class {
|
|
123
|
+
constructor() {
|
|
124
|
+
this.readyState = 0
|
|
125
|
+
this.listeners = new Map()
|
|
126
|
+
setTimeout(() => { this.readyState = 1; if (this.onopen) this.onopen() }, 0)
|
|
127
|
+
emit = (event, data) => {
|
|
128
|
+
for (const h of this.listeners.get(event) ?? []) h({ data: JSON.stringify(data) })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
addEventListener(name, fn) {
|
|
132
|
+
const list = this.listeners.get(name) ?? []
|
|
133
|
+
list.push(fn)
|
|
134
|
+
this.listeners.set(name, list)
|
|
135
|
+
}
|
|
136
|
+
close() { this.readyState = 2 }
|
|
137
|
+
}
|
|
138
|
+
const follow = qa('.dk_pill').find((el) => el.className.indexOf('dk_pillFollow') >= 0)
|
|
139
|
+
if (!follow) throw new Error('没有 FOLLOW 开关')
|
|
140
|
+
const t1 = performance.now()
|
|
141
|
+
follow.click()
|
|
142
|
+
await waitFor(() => emit !== null, 5000)
|
|
143
|
+
const CHUNK = Array.from({ length: 50 }, (_, i) => LINE + ' burst=' + String(i)).join('\\n') + '\\n'
|
|
144
|
+
for (let i = 0; i < 400; i += 1) {
|
|
145
|
+
emit('line', { d: CHUNK })
|
|
146
|
+
if (i % 20 === 0) await sleep(0)
|
|
147
|
+
}
|
|
148
|
+
await sleep(800)
|
|
149
|
+
clearInterval(probe)
|
|
150
|
+
out.burstMs = Math.round(performance.now() - t1)
|
|
151
|
+
out.burstRows = qa('.dk_logLine').length
|
|
152
|
+
out.burstMaxLagMs = Math.round(maxLag)
|
|
153
|
+
out.burstNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
|
|
154
|
+
out.droppedBanner = qa('.dk_banner').some((el) => el.textContent.indexOf('丢弃最早') >= 0)
|
|
155
|
+
out.stillInteractive = qa('.dk_tab').length >= 3 && q('.dk_logBody') !== null
|
|
156
|
+
return JSON.stringify(out)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return JSON.stringify({ error: error instanceof Error ? error.message : String(error), steps: out.steps })
|
|
159
|
+
}
|
|
160
|
+
})()`
|
|
161
|
+
|
|
162
|
+
const chrome = await Chrome.launch({ path: chromePath, extraArgs: ['--no-sandbox'] })
|
|
163
|
+
let raw = '{}'
|
|
164
|
+
try {
|
|
165
|
+
await chrome.attachToPage()
|
|
166
|
+
await chrome.send('Page.enable')
|
|
167
|
+
await chrome.send('Runtime.enable')
|
|
168
|
+
await chrome.send('Page.navigate', { url: `http://127.0.0.1:${String(port)}/harness.html?scenario=docker-panel&theme=light` })
|
|
169
|
+
raw = await chrome.evaluate(MEASURE)
|
|
170
|
+
} finally {
|
|
171
|
+
chrome.close()
|
|
172
|
+
server.close()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let result
|
|
176
|
+
try {
|
|
177
|
+
result = JSON.parse(raw)
|
|
178
|
+
} catch {
|
|
179
|
+
console.error('[log-perf] 量测返回不可解析:' + raw.slice(0, 400))
|
|
180
|
+
process.exit(1)
|
|
181
|
+
}
|
|
182
|
+
if (result.error !== undefined) {
|
|
183
|
+
console.error('[log-perf] 量测失败:' + result.error)
|
|
184
|
+
process.exit(1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log('[log-perf] 5000 行快照:' + String(result.snapshotMs) + 'ms / ' + String(result.snapshotRows) + ' 行 / ' + String(result.snapshotNodes) + ' 个 DOM 节点')
|
|
188
|
+
console.log('[log-perf] FOLLOW 突发 20000 行:' + String(result.burstMs) + 'ms / 事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms / 收敛后 ' + String(result.burstRows) + ' 行 / ' + String(result.burstNodes) + ' 节点')
|
|
189
|
+
console.log('[log-perf] 丢弃横幅 ' + String(result.droppedBanner) + ' / 仍可交互 ' + String(result.stillInteractive))
|
|
190
|
+
|
|
191
|
+
const failures = []
|
|
192
|
+
if (result.snapshotMs > BUDGET.snapshotMs) failures.push('快照渲染 ' + String(result.snapshotMs) + 'ms 超出预算 ' + String(BUDGET.snapshotMs) + 'ms')
|
|
193
|
+
if (result.snapshotRows < BUDGET.snapshotRows) failures.push('快照只渲染了 ' + String(result.snapshotRows) + ' 行(应 ≥ ' + String(BUDGET.snapshotRows) + ')')
|
|
194
|
+
if (result.burstMs > BUDGET.burstMs) failures.push('突发处理 ' + String(result.burstMs) + 'ms 超出预算 ' + String(BUDGET.burstMs) + 'ms')
|
|
195
|
+
if (result.burstMaxLagMs > BUDGET.burstMaxLagMs) failures.push('事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms 超出预算 ' + String(BUDGET.burstMaxLagMs) + 'ms')
|
|
196
|
+
if (result.burstRows > BUDGET.snapshotRows) failures.push('突发后行数 ' + String(result.burstRows) + ' 超过缓冲上限 ' + String(BUDGET.snapshotRows))
|
|
197
|
+
if (result.snapshotNodes > BUDGET.maxNodes || result.burstNodes > BUDGET.maxNodes) failures.push('DOM 节点数超出预算 ' + String(BUDGET.maxNodes))
|
|
198
|
+
if (result.droppedBanner !== true) failures.push('突发撑爆缓冲后没有「已丢弃最早内容」横幅')
|
|
199
|
+
if (result.stillInteractive !== true) failures.push('突发后界面失去可交互性')
|
|
200
|
+
|
|
201
|
+
if (failures.length > 0) {
|
|
202
|
+
console.error('[log-perf] 性能预算未通过:')
|
|
203
|
+
for (const item of failures) console.error(' - ' + item)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
}
|
|
206
|
+
console.log('[log-perf] 性能预算通过')
|
package/scripts/route-smoke.mjs
CHANGED
|
@@ -711,6 +711,27 @@ await test('POST /inspect:容器名注入尝试被白名单拒绝(D37 起回
|
|
|
711
711
|
assert.match(res.body.error, /container 含非法字符/)
|
|
712
712
|
})
|
|
713
713
|
|
|
714
|
+
await test('单目标连不上:回 200 + ok:false(不是不透明 500)——与 target=* 同一口径', async () => {
|
|
715
|
+
/*
|
|
716
|
+
* Windows 真机实测挖出来的不对称:同一句目标侧错误,`target:'*'` 走聚合路径回
|
|
717
|
+
* 200 + groups[].ok:false,单目标却一路冒到外层 catch 变成 500。
|
|
718
|
+
* 「直连」是 127.0.0.1:1 —— 连不上是**运维状况**,不是服务端 bug。
|
|
719
|
+
*/
|
|
720
|
+
const one = await call('POST', '/containers', { target: '直连', all: true })
|
|
721
|
+
assert.equal(one.status, 200, '单目标失败不该是 5xx:' + JSON.stringify(one.body))
|
|
722
|
+
assert.equal(one.body.ok, false)
|
|
723
|
+
assert.equal(one.body.target, '直连')
|
|
724
|
+
assert.equal(typeof one.body.error, 'string')
|
|
725
|
+
assert.ok(one.body.error.length > 0, '必须带可读原因')
|
|
726
|
+
// 与聚合口径对照:同一次失败在 target='*' 下也是 ok:false
|
|
727
|
+
const all = await call('POST', '/containers', { target: '*', all: true })
|
|
728
|
+
assert.equal(all.status, 200)
|
|
729
|
+
const group = (all.body.groups ?? []).find((row) => row.target === '直连')
|
|
730
|
+
assert.equal(group?.ok, false, '聚合路径里这条目标也该是 ok:false')
|
|
731
|
+
// 载荷形状不能串:单目标失败时不该顺手给出一个空的 containers 列表
|
|
732
|
+
assert.equal(one.body.containers, undefined)
|
|
733
|
+
})
|
|
734
|
+
|
|
714
735
|
await test('非 loopback 请求 403', async () => {
|
|
715
736
|
const res = await call('GET', '/config', undefined, '10.0.0.9')
|
|
716
737
|
assert.equal(res.status, 403)
|
package/scripts/smoke.mjs
CHANGED
|
@@ -92,13 +92,49 @@ test('parsePsJson:字段映射 / compose 标签 / 端口', () => {
|
|
|
92
92
|
assert.equal(web.health, 'healthy')
|
|
93
93
|
assert.equal(web.composeProject, 'shop')
|
|
94
94
|
assert.equal(web.composeService, 'web')
|
|
95
|
+
// 双栈(0.0.0.0 + [::])是同一次 -p 的两种展开,只留首条(D130)——此前这条断言
|
|
96
|
+
// 把「同一个端口出现两遍」当成期望值钉住了,所以线上一直没人发现(docker_ps 的
|
|
97
|
+
// 渲染又会丢掉 hostIp,看起来就是纯粹的重复)
|
|
95
98
|
assert.deepEqual(web.ports, [
|
|
96
99
|
{ hostIp: '0.0.0.0', hostPort: 8080, containerPort: 80, protocol: 'tcp' },
|
|
97
|
-
{ hostIp: '[::]', hostPort: 8080, containerPort: 80, protocol: 'tcp' },
|
|
98
100
|
])
|
|
99
101
|
assert.equal(cache.state, 'exited')
|
|
100
102
|
assert.equal(cache.composeProject, null)
|
|
101
103
|
assert.deepEqual(cache.ports, [{ containerPort: 6379, protocol: 'tcp' }])
|
|
104
|
+
// 网络:ps 的 .Networks 是逗号分隔串(D131)——host 网络容器靠它才能说清「端口为什么空」
|
|
105
|
+
assert.deepEqual(web.networks, ['shop_default'])
|
|
106
|
+
assert.deepEqual(cache.networks, [])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('suggestContainerNames:inspect 未命中的候选(D132)', () => {
|
|
110
|
+
const names = ['607023340cbb_rmqnamesrv', 'rmqbroker', 'redis', 'rocketmq-dashboard']
|
|
111
|
+
// 前缀优先于包含;compose 前缀过的真名排在前面
|
|
112
|
+
assert.deepEqual(docker.suggestContainerNames('rmqnamesrv', names), ['607023340cbb_rmqnamesrv'])
|
|
113
|
+
assert.deepEqual(docker.suggestContainerNames('rmq', names), ['rmqbroker', '607023340cbb_rmqnamesrv'])
|
|
114
|
+
// 完全无关 / 空输入 → 不给提示
|
|
115
|
+
assert.deepEqual(docker.suggestContainerNames('nope', names), [])
|
|
116
|
+
assert.deepEqual(docker.suggestContainerNames(' ', names), [])
|
|
117
|
+
// 最多 3 个
|
|
118
|
+
assert.equal(docker.suggestContainerNames('a', ['a1', 'a2', 'a3', 'a4']).length, 3)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('parsePsJson:host 网络容器无端口但有网络(D131)', () => {
|
|
122
|
+
const rows = docker.parsePsJson(JSON.stringify({
|
|
123
|
+
ID: 'host1234567890abcdef',
|
|
124
|
+
Image: 'rmq:1',
|
|
125
|
+
Names: 'rocketmq-dashboard',
|
|
126
|
+
Networks: 'host',
|
|
127
|
+
Ports: '',
|
|
128
|
+
State: 'running',
|
|
129
|
+
Status: 'Up 2 days',
|
|
130
|
+
}))
|
|
131
|
+
assert.deepEqual(rows[0].ports, [])
|
|
132
|
+
assert.deepEqual(rows[0].networks, ['host'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('parsePsJson:多网络(逗号分隔 + 空格)', () => {
|
|
136
|
+
const rows = docker.parsePsJson(JSON.stringify({ ID: 'x', Names: 'a', Networks: 'net-a, net-b', State: 'running', Status: 'Up 1 minute' }))
|
|
137
|
+
assert.deepEqual(rows[0].networks, ['net-a', 'net-b'])
|
|
102
138
|
})
|
|
103
139
|
|
|
104
140
|
test('parsePsJson:State 缺失时从 Status 推导(老版本 docker)', () => {
|
|
@@ -122,6 +158,28 @@ test('parsePorts:多段映射与去重', () => {
|
|
|
122
158
|
])
|
|
123
159
|
})
|
|
124
160
|
|
|
161
|
+
test('parsePorts:双栈(IPv4/IPv6 通配)是同一次发布,只留一条(D130)', () => {
|
|
162
|
+
// 现场实测(redis / nacos / higress-ai):ps 的 .Ports 里 0.0.0.0 与 [::] 各一份,
|
|
163
|
+
// 渲染丢掉 hostIp 后看起来像「同一个端口发布了两遍」
|
|
164
|
+
assert.deepEqual(docker.parsePorts('0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp'), [
|
|
165
|
+
{ hostIp: '0.0.0.0', hostPort: 6379, containerPort: 6379, protocol: 'tcp' },
|
|
166
|
+
])
|
|
167
|
+
// 无括号的 IPv6 通配(docker 某些版本/驱动输出)同样归一
|
|
168
|
+
assert.deepEqual(docker.parsePorts(':::8848->8848/tcp, [::]:8848->8848/tcp'), [
|
|
169
|
+
{ hostIp: '::', hostPort: 8848, containerPort: 8848, protocol: 'tcp' },
|
|
170
|
+
])
|
|
171
|
+
// 真实的两种绑定(回环 + 通配)必须继续算两条:只归一化通配符那一族
|
|
172
|
+
assert.deepEqual(docker.parsePorts('127.0.0.1:8080->80/tcp, 0.0.0.0:8080->80/tcp'), [
|
|
173
|
+
{ hostIp: '127.0.0.1', hostPort: 8080, containerPort: 80, protocol: 'tcp' },
|
|
174
|
+
{ hostIp: '0.0.0.0', hostPort: 8080, containerPort: 80, protocol: 'tcp' },
|
|
175
|
+
])
|
|
176
|
+
// 「仅暴露未发布」的容器端口不是重复,别被一起吃掉
|
|
177
|
+
assert.deepEqual(docker.parsePorts('80/tcp, 84->84/tcp, 84->84/tcp'), [
|
|
178
|
+
{ containerPort: 80, protocol: 'tcp' },
|
|
179
|
+
{ hostPort: 84, containerPort: 84, protocol: 'tcp' },
|
|
180
|
+
])
|
|
181
|
+
})
|
|
182
|
+
|
|
125
183
|
await test('凭据解析:官方凭据服务优先,缺失/未命中再退回环境变量', async () => {
|
|
126
184
|
const { resolveSecretVia } = exec
|
|
127
185
|
assert.equal(typeof resolveSecretVia, 'function', '缺少 resolveSecretVia(纯核心)')
|