dsh-agent-board 1.7.7 → 1.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/index.mjs +1 -0
- package/lib/cleanup.mjs +256 -0
- package/lib/client/board-list.js +12 -8
- package/lib/client/dashboard.js +133 -7
- package/lib/client/kernel.js +62 -10
- package/lib/client/task-detail.js +13 -5
- package/lib/client.js +220 -30
- package/lib/core.mjs +30 -2
- package/lib/dispatch.mjs +82 -5
- package/lib/rpc.mjs +154 -25
- package/lib/store.mjs +86 -62
- package/package.json +1 -1
- package/test/cleanup.test.mjs +341 -0
- package/test/conditional-get-tasks.test.mjs +96 -0
- package/test/core.test.mjs +294 -18
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/scoreboard-trends-client.test.mjs +21 -0
- package/test/store-data-safety.test.mjs +105 -0
- package/test/ui-zoom-client.test.mjs +76 -0
- package/test/verify-timing.test.mjs +26 -0
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
import { test } from 'node:test'
|
|
7
7
|
import assert from 'node:assert/strict'
|
|
8
8
|
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
9
10
|
import path from 'node:path'
|
|
10
11
|
import { createMockCtx } from './helpers/mock-ctx.mjs'
|
|
11
12
|
import * as plugin from '../index.mjs'
|
|
13
|
+
import { createStore } from '../lib/store.mjs'
|
|
12
14
|
|
|
13
15
|
function boardFile(env) { return path.join(env.home, '.dsh', 'tasks-' + env.sid + '.json') }
|
|
14
16
|
|
|
@@ -110,3 +112,106 @@ test('③ 自动 salvage:完整 JSON 尾部夹杂撕裂残片 → 截断抢救
|
|
|
110
112
|
assert.ok(msg, 'notify 通知 owner(抢救型)')
|
|
111
113
|
} finally { await env.cleanup() }
|
|
112
114
|
})
|
|
115
|
+
|
|
116
|
+
// ===== ④ P1 根修(task-mv1e17x9):tmp 唯一名 + 四条锁外写路径归一(原子写时代撕裂根修)=====
|
|
117
|
+
|
|
118
|
+
// 直连 store 层的最小环境:只测 wt() 原子写 / tmp 残件清理,不拉起整个插件。
|
|
119
|
+
// 独立接管 process.env.HOME(与 createMockCtx 同款纪律:HOME 是进程级,用例串行 + finally 恢复)。
|
|
120
|
+
function makeRawStore() {
|
|
121
|
+
var home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-board-wt-'))
|
|
122
|
+
fs.mkdirSync(path.join(home, '.dsh'), { recursive: true })
|
|
123
|
+
var prevHome = process.env.HOME
|
|
124
|
+
var hadHome = Object.prototype.hasOwnProperty.call(process.env, 'HOME')
|
|
125
|
+
process.env.HOME = home
|
|
126
|
+
var state = { fileLocks: {}, cyclePending: {}, poolHealth: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {} }
|
|
127
|
+
var store = createStore({}, state, { sessionCwd: function () { return home } })
|
|
128
|
+
return {
|
|
129
|
+
store: store, home: home,
|
|
130
|
+
cleanup: function () {
|
|
131
|
+
if (hadHome) process.env.HOME = prevHome
|
|
132
|
+
else delete process.env.HOME
|
|
133
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch (_) {}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test('④ tmp 唯一名(源码级):wt() 的 tmp 名 = pid + 时间戳 + 进程内自增序号', () => {
|
|
139
|
+
var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
|
|
140
|
+
assert.match(sto, /process\.pid/) // tmp 名含 pid(跨进程也唯一)
|
|
141
|
+
assert.match(sto, /Date\.now\(\)/) // 含时间戳
|
|
142
|
+
assert.match(sto, /\+\+tmpSeq/) // 含进程内自增序号(同 ms 并发也唯一)
|
|
143
|
+
assert.match(sto, /\+ '\.tmp'/) // 仍是 .tmp 后缀
|
|
144
|
+
assert.match(sto, /var tmpSeq = 0/) // 序号计数器声明(模块级,跨 store 实例共享)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('④ tmp 唯一名(行为级):两个并发 3MB 写后文件必是其中一份完整 JSON,且无 .tmp 残件', async () => {
|
|
148
|
+
var env = makeRawStore()
|
|
149
|
+
var sid = 'session-wt-' + Date.now().toString(36)
|
|
150
|
+
try {
|
|
151
|
+
var fillerA = 'A'.repeat(3 * 1024 * 1024)
|
|
152
|
+
var fillerB = 'B'.repeat(3 * 1024 * 1024)
|
|
153
|
+
var dA = { ownerSession: sid, tasks: [], marker: 'A', filler: fillerA }
|
|
154
|
+
var dB = { ownerSession: sid, tasks: [], marker: 'B', filler: fillerB }
|
|
155
|
+
var a = JSON.stringify(dA), b = JSON.stringify(dB)
|
|
156
|
+
await Promise.all([env.store.wt(sid, dA), env.store.wt(sid, dB)])
|
|
157
|
+
var p = path.join(env.home, '.dsh', 'tasks-' + sid + '.json')
|
|
158
|
+
var final = fs.readFileSync(p, 'utf8')
|
|
159
|
+
assert.ok(final === a || final === b, '最终文件必是其中一份完整 JSON(不是拼接怪)')
|
|
160
|
+
var leftovers = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return /\.tmp$/.test(f) })
|
|
161
|
+
assert.equal(leftovers.length, 0, '成功写盘后无 .tmp 残件')
|
|
162
|
+
} finally { env.cleanup() }
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('④ 无 .tmp 残件(行为级):rename 非瞬时失败后清理自己的 tmp', async () => {
|
|
166
|
+
var env = makeRawStore()
|
|
167
|
+
var sid = 'session-wt-fail-' + Date.now().toString(36)
|
|
168
|
+
var origRename = fs.promises.rename
|
|
169
|
+
try {
|
|
170
|
+
var p = path.join(env.home, '.dsh', 'tasks-' + sid + '.json')
|
|
171
|
+
await env.store.wt(sid, { ownerSession: sid, tasks: [] }) // 先正常写一次
|
|
172
|
+
assert.ok(fs.existsSync(p), '前置:主板已落盘')
|
|
173
|
+
fs.promises.rename = async function () { var e = new Error('EIO injected'); e.code = 'EIO'; throw e }
|
|
174
|
+
try { await env.store.wt(sid, { ownerSession: sid, tasks: [] }) } catch (_) {}
|
|
175
|
+
var leftovers = fs.readdirSync(path.join(env.home, '.dsh')).filter(function (f) { return /\.tmp$/.test(f) })
|
|
176
|
+
assert.equal(leftovers.length, 0, 'rename 失败后无 .tmp 残件(残件已被清理)')
|
|
177
|
+
} finally {
|
|
178
|
+
fs.promises.rename = origRename
|
|
179
|
+
env.cleanup()
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('④ 锁归一(源码级):四条锁外写路径全部归一进 withLock,dispatch 不再直写 wt', () => {
|
|
184
|
+
var sto = fs.readFileSync(new URL('../lib/store.mjs', import.meta.url), 'utf8')
|
|
185
|
+
var disp = fs.readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
186
|
+
// 锁感知写回 + 不可重入死锁说明必须显式存在
|
|
187
|
+
assert.match(sto, /function lockAware\(sid, locked, fn\)/)
|
|
188
|
+
assert.match(sto, /withLock 是简单 promise 链,不可重入/)
|
|
189
|
+
assert.match(sto, /async function adoptBoard\(sid, cand, cwd, locked\)/)
|
|
190
|
+
assert.match(sto, /async function recoverCorrupt\(sid, raw, locked\)/)
|
|
191
|
+
assert.match(sto, /return lockAware\(sid, locked, async function \(\)/)
|
|
192
|
+
assert.match(sto, /await rt\(sid, true\)/) // mutateLocked 锁内调用 rt 传 true
|
|
193
|
+
// poolCycle 空闲快进清理:不再直写 wt,改走 mutateLocked(锁内重读重判)
|
|
194
|
+
assert.doesNotMatch(disp, /await wt\(/)
|
|
195
|
+
assert.doesNotMatch(disp, /deps\.wt/)
|
|
196
|
+
assert.match(disp, /mutateLocked\(sid, function \(d\) \{/)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('④ poolCycle 空闲清理(行为级):残留忙碌 poolStatus 经锁被清空', async () => {
|
|
200
|
+
var env = createMockCtx()
|
|
201
|
+
plugin.apply(env.ctx)
|
|
202
|
+
try {
|
|
203
|
+
await createTask(env, 'idle1') // 建卡 + touchSession 入 knownSessions
|
|
204
|
+
// 模拟 DSH 重启:内存 runs 已空、文件里残留重启前的忙碌快照 + 无活跃任务 → 走空闲快进清理
|
|
205
|
+
env.patchBoard(function (d) {
|
|
206
|
+
d.tasks = []
|
|
207
|
+
d.poolStatus = { workers: [{ id: 'ghost-w', taskId: 'x', role: 'worker' }], verifiers: [{ id: 'ghost-v', taskId: 'y', role: 'verifier' }] }
|
|
208
|
+
})
|
|
209
|
+
await env.clock.tick() // 触发 15s 心跳 → poolCycle → 空闲清理
|
|
210
|
+
await env.waitFor(function () {
|
|
211
|
+
var b = env.board()
|
|
212
|
+
return b && b.poolStatus.workers.length === 0 && b.poolStatus.verifiers.length === 0
|
|
213
|
+
})
|
|
214
|
+
var b = env.board()
|
|
215
|
+
assert.equal(b.poolStatus.workers.length + b.poolStatus.verifiers.length, 0, '残留忙碌 poolStatus 已被清空')
|
|
216
|
+
} finally { await env.cleanup() }
|
|
217
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// dsh-agent-board — 界面缩放档位 client 侧单测(test/ui-zoom-client.test.mjs,task-mv58e01j)
|
|
2
|
+
// 背景:用户 2026-10-12 反馈「字体整体偏小」;面板 ~317 处内联 fontSize px 写死,逐处改不值 →
|
|
3
|
+
// 定稿方案=面板根容器 CSS zoom 等比缩放(Chromium 支持,字体/图标/间距一并放大,只影响本面板)。
|
|
4
|
+
// client 无渲染 harness(react 不在包依赖,渲染由 dsh web 客户端运行时注入)——与历卡同一纪律:
|
|
5
|
+
// 全部走源码级断言(kernel.js / dashboard.js 模块源 + client.js 重组装产物 + README 双份同步)。
|
|
6
|
+
// ① 状态与持久化——state.uiZoom(localStorage 键 dsh-agent-board-ui-zoom,白名单五档,脏值/异常回退 1)
|
|
7
|
+
// ② 根容器接线——TopPanel 顶层 div style 挂 zoom: uiZoom(订阅值,notify 即时生效)
|
|
8
|
+
// ③ 设置弹层档位——⚙ 弹层「界面」小节 ZoomCfg 下拉:90/100/110/125/150%,选中即 setItem+state+notify
|
|
9
|
+
// ④ 弹层坐标补偿——zoom 子树内 fixed 弹层 top/right/maxH 除以档位还原 CSS px(仍贴按钮、不溢出视口)
|
|
10
|
+
// ⑤ README 双份——根/包逐字一致 + 看板 UI 区补「界面缩放」说明行
|
|
11
|
+
// ⑥ 产物重组装——关键标记逐一出现在 lib/client.js(需 npm run build-client)
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
17
|
+
const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
|
|
18
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
19
|
+
|
|
20
|
+
// ===== ① 状态与持久化(kernel state + localStorage 读取,缺省 1,白名单校验)=====
|
|
21
|
+
test('①状态:UI_ZOOM_KEY/UI_ZOOM_LEVELS 常量 + readUiZoom 白名单回退 + state.uiZoom 初始化', () => {
|
|
22
|
+
assert.ok(kernel.indexOf("var UI_ZOOM_KEY = 'dsh-agent-board-ui-zoom'") >= 0, '缺 localStorage 键常量')
|
|
23
|
+
assert.match(kernel, /var UI_ZOOM_LEVELS = \[0\.9, 1, 1\.1, 1\.25, 1\.5\]/, '缺五档白名单(90/100/110/125/150%)')
|
|
24
|
+
// 读取:parseFloat + 白名单命中才采纳,脏值/异常一律回退 1
|
|
25
|
+
assert.match(kernel, /function readUiZoom\(\) \{ try \{ var v = parseFloat\(localStorage\.getItem\(UI_ZOOM_KEY\)\); return UI_ZOOM_LEVELS\.indexOf\(v\) >= 0 \? v : 1 \} catch \(_\) \{ return 1 \} \}/)
|
|
26
|
+
// state 字段初始化(读 localStorage,不开口在 host)
|
|
27
|
+
assert.match(kernel, /verifyUserGuide: true, uiZoom: readUiZoom\(\)/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// ===== ② 根容器接线(TopPanel 顶层 div style.zoom + 订阅链)=====
|
|
31
|
+
test('②接线:TopPanel 订阅 uiZoom(useState + update 链)+ 根容器 style 挂 zoom', () => {
|
|
32
|
+
// 订阅:useState 初值读 state.uiZoom,update 链里 setUiZoom(state.uiZoom)(notify 即时生效的渲染通路)
|
|
33
|
+
assert.match(kernel, /useState\(state\.uiZoom\), uiZoom = _uzm\[0\], setUiZoom = _uzm\[1\]/)
|
|
34
|
+
assert.match(kernel, /setCrd\(state\.cleanupRetentionDays\); setUiZoom\(state\.uiZoom\)/)
|
|
35
|
+
// 根容器(面板顶层 fixed div)style 挂 zoom——只此一行接管全面板缩放,不动任何既有 fontSize
|
|
36
|
+
assert.match(kernel, /zIndex: 900, zoom: uiZoom, background: C\.bg/)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ===== ③ 设置弹层档位(⚙ 弹层「界面」小节 ZoomCfg 下拉,选中即持久化+生效)=====
|
|
40
|
+
test('③档位:ZoomCfg 下拉五档 + 选中即 localStorage.setItem + state.uiZoom + notify + 弹层「界面」小节挂载', () => {
|
|
41
|
+
assert.match(dash, /function ZoomCfg\(\)/, '缺 ZoomCfg 组件')
|
|
42
|
+
// 五档选项由白名单渲染,默认档标注
|
|
43
|
+
assert.match(dash, /UI_ZOOM_LEVELS\.map\(function \(z\) \{ return React\.createElement\('option', \{ key: String\(z\), value: String\(z\) \}, Math\.round\(z \* 100\) \+ '%' \+ \(z === 1 \? '(默认)' : ''\)\) \}/)
|
|
44
|
+
// 选中口径:非法档位不采纳 → 写 state → localStorage 持久化 → notify 即时生效
|
|
45
|
+
assert.match(dash, /if \(UI_ZOOM_LEVELS\.indexOf\(n\) < 0\) return/)
|
|
46
|
+
assert.match(dash, /state\.uiZoom = n/)
|
|
47
|
+
assert.match(dash, /localStorage\.setItem\(UI_ZOOM_KEY, String\(n\)\)/)
|
|
48
|
+
// 弹层「界面」小节挂载 ZoomCfg(存储清理之后、弹层收尾之前)
|
|
49
|
+
assert.match(dash, /'界面'\),\s*React\.createElement\('div', \{ style: \{ display: 'flex', gap: 8, alignItems: 'center' \} \},\s*React\.createElement\(ZoomCfg, null\)/)
|
|
50
|
+
assert.ok(dash.indexOf('界面缩放') >= 0, '弹层缺「界面缩放」标签')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ===== ④ 弹层坐标补偿(zoom 子树内 fixed 弹层的 top/right/maxH 还原)=====
|
|
54
|
+
test('④补偿:⚙ 弹层定位除以缩放档位(getBoundingClientRect 可视坐标 → CSS px),zoom=1 时与旧行为一致', () => {
|
|
55
|
+
assert.match(dash, /var z = state\.uiZoom \|\| 1/)
|
|
56
|
+
assert.match(dash, /setPos\(\{ top: \(r\.bottom \+ 4\) \/ z, right: Math\.max\(8, window\.innerWidth - r\.right\) \/ z, maxH: Math\.max\(140, \(window\.innerHeight - r\.bottom - 16\) \/ z\) \}\)/)
|
|
57
|
+
// maxHeight 改读补偿后的 pos.maxH(不再用未补偿的 calc(100vh - ...))
|
|
58
|
+
assert.match(dash, /maxHeight: \(pos\.maxH \|\| 300\) \+ 'px'/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// ===== ⑤ README 双份 =====
|
|
62
|
+
test('⑤README 双份:根/包逐字一致 + 看板 UI 区补「界面缩放」说明行(档位/键名/即时生效)', () => {
|
|
63
|
+
const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
64
|
+
const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
65
|
+
assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
|
|
66
|
+
assert.ok(r1.indexOf('界面缩放') >= 0, 'README 缺「界面缩放」说明行')
|
|
67
|
+
assert.ok(r1.indexOf('dsh-agent-board-ui-zoom') >= 0, 'README 说明行缺 localStorage 键名')
|
|
68
|
+
assert.ok(r1.indexOf('90% / 100%(默认)/ 110% / 125% / 150%') >= 0, 'README 说明行缺五档清单')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ===== ⑥ 产物重组装 =====
|
|
72
|
+
test('⑥产物:模块源关键标记逐一出现在 lib/client.js(需 npm run build-client 重组装)', () => {
|
|
73
|
+
for (const m of ['dsh-agent-board-ui-zoom', 'UI_ZOOM_LEVELS', 'readUiZoom', 'zoom: uiZoom', 'ZoomCfg', '界面缩放']) {
|
|
74
|
+
assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
|
|
75
|
+
}
|
|
76
|
+
})
|
|
@@ -162,6 +162,32 @@ test('② 回归:无活跃 rec 的 stale verifying 卡(terminate 空转场
|
|
|
162
162
|
assert.equal(r3.ok, true); assert.equal(board.tasks[0].status, 'archived')
|
|
163
163
|
})
|
|
164
164
|
|
|
165
|
+
// ===== ② 死结窗口根修(task-mv1pqy27):terminate 认领位清理与状态无关 + 关 run 结局 terminated =====
|
|
166
|
+
test('② 死结窗口:resolved/cancelled 态 terminate(rec 仍在内存表)→ 认领位清 + run 关账,立即 archive 放行', async () => {
|
|
167
|
+
// resolved 态窗口:卡已 resolved,但 verifier run 的 rec 还挂在内存表(~50ms 结算窗口)
|
|
168
|
+
const board = mkBoard([mkTask({ id: 'a6', status: 'resolved', verifierRun: 'run-vX', runs: [{ role: 'verifier', id: 'run-vX', outcome: 'running' }] })])
|
|
169
|
+
const runs = { [FULL_SID]: { 'a6': { id: 'run-vX', role: 'verifier', settled: false } } }
|
|
170
|
+
const closed = []
|
|
171
|
+
const h = mkRpcHarness(board, runs, { closeRunHistory: async (sid, taskId, runId, outcome) => { closed.push({ taskId: taskId, runId: runId, outcome: outcome }); var tt = board.tasks.find(function (x) { return x.id === taskId }); if (tt && Array.isArray(tt.runs)) { var rr = tt.runs[tt.runs.length - 1]; if (rr && rr.id === runId && rr.outcome === 'running') { rr.outcome = outcome; rr.endedAt = '2026-01-01T00:00:01Z' } } } })
|
|
172
|
+
const r = await h['terminate-agent']({ taskId: 'a6' })
|
|
173
|
+
assert.equal(r.ok, true); assert.equal(r.terminated, 'verifier:a6')
|
|
174
|
+
assert.equal(board.tasks[0].verifierRun, null) // 状态无关:resolved 态也清 verifierRun(旧代码只在 verifying 清 → 死结)
|
|
175
|
+
assert.deepEqual(closed, [{ taskId: 'a6', runId: 'run-vX', outcome: 'terminated' }]) // 关 run 结局 terminated
|
|
176
|
+
assert.equal(board.tasks[0].runs[0].outcome, 'terminated')
|
|
177
|
+
const r3 = await h['archive-task']({ taskId: 'a6' })
|
|
178
|
+
assert.equal(r3.ok, true); assert.equal(board.tasks[0].status, 'archived') // 立即 archive 放行(修复前永久拒)
|
|
179
|
+
|
|
180
|
+
// cancelled 态窗口:worker run 的 rec 仍在内存表,claimedBy 仍指已终止 run(closeRunHistory 未注入 = no-op 兜底,只靠认领位清)
|
|
181
|
+
const board2 = mkBoard([mkTask({ id: 'a7', status: 'cancelled', claimedBy: 'run-wX', claimedAt: '2026-01-01T00:00:00Z', runs: [{ role: 'worker', id: 'run-wX', outcome: 'running' }] })])
|
|
182
|
+
const runs2 = { [FULL_SID]: { 'a7': { id: 'run-wX', role: 'worker', settled: false } } }
|
|
183
|
+
const h2 = mkRpcHarness(board2, runs2)
|
|
184
|
+
const rw = await h2['terminate-agent']({ taskId: 'a7' })
|
|
185
|
+
assert.equal(rw.ok, true); assert.equal(rw.terminated, 'worker:a7')
|
|
186
|
+
assert.equal(board2.tasks[0].claimedBy, null); assert.equal(board2.tasks[0].claimedAt, null) // 状态无关:cancelled 态也清 claimedBy
|
|
187
|
+
const rw3 = await h2['archive-task']({ taskId: 'a7' })
|
|
188
|
+
assert.equal(rw3.ok, true); assert.equal(board2.tasks[0].status, 'archived') // 取消卡立即 archive 放行
|
|
189
|
+
})
|
|
190
|
+
|
|
165
191
|
// ===== ③④⑤ 迟到 verdict 落账(工具通道 board_verdict)=====
|
|
166
192
|
test('③ 迟到 approved:board_verdict 落到已 resolved 卡 → 落 lateVerdict,不炸、状态不动', async () => {
|
|
167
193
|
const board = mkBoard([mkTask({ id: 'l1', status: 'resolved', verifiedAt: '2026-01-01T00:01:00Z' })])
|