dsh-better-tips 0.12.1 → 0.13.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/CHANGELOG.md +23 -0
- package/README.md +54 -122
- package/client.js +268 -124
- package/index.js +202 -12
- package/package.json +1 -1
- package/test/client.test.mjs +677 -188
- package/test/host.test.mjs +417 -3
package/test/client.test.mjs
CHANGED
|
@@ -116,22 +116,23 @@ function assertMainAdvice (row, fragment) {
|
|
|
116
116
|
}
|
|
117
117
|
function assertOriginal (row, original) {
|
|
118
118
|
const expected = [original.code ? `[${original.code}]` : '', original.message].filter(Boolean).join(' ')
|
|
119
|
-
const originals = withClass(row.tree, 'bt_original')
|
|
120
|
-
assert.ok(originals.some((node) => text(node) === expected), 'complete original remains verbatim in its disclosure')
|
|
121
|
-
|
|
119
|
+
const originals = [...withClass(row.tree, 'bt_original'), ...withClass(row.tree, 'bt_preview'), ...withClass(row.tree, 'bt_attemptPreview')]
|
|
120
|
+
assert.ok(originals.some((node) => text(node) === expected), 'complete original remains verbatim in either its unchanged preview or full-original disclosure')
|
|
121
|
+
const fallback = withClass(row.tree, 'bt_nativeFallback', true)
|
|
122
|
+
assert.equal(withClass(row.tree, 'bt_original', true).length, fallback.length ? 1 : 0, 'only explicit native fallback may show complete original by default')
|
|
122
123
|
assert.ok(row.full.includes(expected), 'full error evidence is retained')
|
|
123
124
|
}
|
|
124
125
|
function assertCaptureAdvice (row, fragment) {
|
|
125
126
|
const advice = withClass(row.tree, 'bt_captureAdvice')
|
|
126
127
|
assert.ok(advice.length > 0, 'capture-specific advice is available')
|
|
127
|
-
assert.
|
|
128
|
+
assert.ok(advice.every((node) => text(node).includes('排查')), 'capture-specific advice stays explicitly labelled')
|
|
128
129
|
assert.ok(advice.some((node) => text(node).includes(fragment)), `missing capture-specific advice ${fragment}`)
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
/** Hook instances follow component type/key/path, rather than sharing sibling state. */
|
|
132
133
|
async function renderRow (key, props, {
|
|
133
134
|
response = { items: [], records: [], capture: { installed: true, records: 0 } },
|
|
134
|
-
error, status, pending = false, sessions, clickLog = false, interact, respond, timers,
|
|
135
|
+
error, status, pending = false, sessions, clickLog = false, interact, respond, timers, environment, environmentRespond,
|
|
135
136
|
openDetails = true,
|
|
136
137
|
} = {}) {
|
|
137
138
|
const mounted = mountFactory(sessions)
|
|
@@ -140,6 +141,7 @@ async function renderRow (key, props, {
|
|
|
140
141
|
const originalClearTimeout = globalThis.clearTimeout
|
|
141
142
|
if (timers) { globalThis.setTimeout = timers.set; globalThis.clearTimeout = timers.clear }
|
|
142
143
|
const calls = []
|
|
144
|
+
const environmentCalls = []
|
|
143
145
|
const instances = new Map()
|
|
144
146
|
const domOpen = new Map()
|
|
145
147
|
const typeIds = new Map()
|
|
@@ -150,6 +152,12 @@ async function renderRow (key, props, {
|
|
|
150
152
|
let renderedInstances
|
|
151
153
|
const typeId = (type) => { if (!typeIds.has(type)) typeIds.set(type, ++nextType); return typeIds.get(type) }
|
|
152
154
|
globalThis.fetch = (url, options) => {
|
|
155
|
+
if (String(url) === 'api/better-tips/environment') {
|
|
156
|
+
environmentCalls.push({ url: String(url), options })
|
|
157
|
+
if (environmentRespond) return environmentRespond(options, environmentCalls.length)
|
|
158
|
+
return Promise.resolve({ ok: true, json: async () => environment ?? { runtime: {
|
|
159
|
+
...response?.runtime, enabledPlugins: [{ name: 'dsh-better-tips', version: '0.12.0' }], pluginsStatus: 'complete' } } })
|
|
160
|
+
}
|
|
153
161
|
calls.push({ url: String(url), options })
|
|
154
162
|
if (respond) return respond(String(url), options, calls.length)
|
|
155
163
|
if (pending) return new Promise(() => {})
|
|
@@ -194,7 +202,7 @@ async function renderRow (key, props, {
|
|
|
194
202
|
result.children = node.children.map((child, index) => render(child, `${path}/${child?.props?.key ?? index}`))
|
|
195
203
|
return result
|
|
196
204
|
}
|
|
197
|
-
const snapshot = (tree) => ({ tree, calls, primary: text(tree, 'collapsed'), visible: text(tree, true), full: text(tree) })
|
|
205
|
+
const snapshot = (tree) => ({ tree, calls, environmentCalls, primary: text(tree, 'collapsed'), visible: text(tree, true), full: text(tree) })
|
|
198
206
|
const settle = async () => {
|
|
199
207
|
for (let pass = 0; pass < 30; pass++) {
|
|
200
208
|
dirty = false
|
|
@@ -272,11 +280,326 @@ function http (status, statusText, rawFailure = { message: `${status} status cod
|
|
|
272
280
|
}
|
|
273
281
|
}
|
|
274
282
|
const details = (items) => ({ items, capture: { installed: true, records: 78, recent: [] } })
|
|
283
|
+
const deferred = () => {
|
|
284
|
+
let resolve, reject
|
|
285
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
|
286
|
+
return { promise, resolve, reject }
|
|
287
|
+
}
|
|
288
|
+
const jsonResult = (data) => ({ ok: true, json: async () => data })
|
|
289
|
+
async function withClipboard (operation, write) {
|
|
290
|
+
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
|
291
|
+
const copied = []
|
|
292
|
+
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { writeText: write ?? (async (value) => { copied.push(value) }) } } })
|
|
293
|
+
try { return await operation(copied) } finally {
|
|
294
|
+
if (previous) Object.defineProperty(globalThis, 'navigator', previous)
|
|
295
|
+
else delete globalThis.navigator
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const reportPayload = (items = [socket()]) => ({ ...details(items), runtime: { dshVersion: '0.2.2-rc.2', pluginVersion: '0.12.0' } })
|
|
299
|
+
|
|
275
300
|
function assertQuiet (primary) {
|
|
276
301
|
assert.doesNotMatch(primary, /gateway\.example|\/v1\/chat\/completions|Caddy|765432|765\.4|request-debug-id/)
|
|
277
302
|
assert.doesNotMatch(primary, /推测|可能是同一次|例如|捕获器|进程内失败|本轮命中|未捕获|读取失败|正在读取|暂无捕获|连接失败(|没有保留原因/)
|
|
278
303
|
}
|
|
279
304
|
|
|
305
|
+
test('advice and report follow capture; raw details and native error are sibling disclosures', async () => {
|
|
306
|
+
const row = await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload() })
|
|
307
|
+
const children = row.tree.children
|
|
308
|
+
const index = (name) => children.findIndex((node) => String(node.props?.className ?? '').split(/\s+/).includes(name))
|
|
309
|
+
assert.ok(index('bt_network') < index('bt_advice'))
|
|
310
|
+
assert.ok(index('bt_advice') < index('bt_report'))
|
|
311
|
+
assert.ok(index('bt_report') < index('bt_detailPair'))
|
|
312
|
+
assert.equal(withClass(row.tree, 'bt_failureTitle').length, 1)
|
|
313
|
+
const pair = withClass(row.tree, 'bt_detailPair')[0]
|
|
314
|
+
assert.equal(pair.children.length, 2)
|
|
315
|
+
assert.ok(pair.children.every((node) => node.type === 'details'))
|
|
316
|
+
assert.match(text(pair.children[0].children[0]), /底层原始详情/)
|
|
317
|
+
assert.match(text(pair.children[1].children[0]), /DSH 原生报错/)
|
|
318
|
+
assert.equal(withClass(withClass(row.tree, 'bt_network')[0], 'bt_networkDetails').length, 0)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('retry screenshot keeps current error then one advice before copy and folded history', async () => {
|
|
322
|
+
const first = { ...retry().node.data.current, seq: 10, retry: 1 }
|
|
323
|
+
const second = { ...retry().node.data.current, seq: 20, retry: 2 }
|
|
324
|
+
const row = await renderRow('model-retry', { ...retry(), node: { data: { attempts: [first, second], current: second } } }, { openDetails: false, response: details([socket()]) })
|
|
325
|
+
assert.equal(row.tree.children[0].props.className, 'bt_failureTitle')
|
|
326
|
+
assert.equal(row.tree.children[1].props.className, 'bt_section bt_history')
|
|
327
|
+
const history = withClass(row.tree, 'bt_history')[0]
|
|
328
|
+
assert.equal(history.children[0].props.key, 20)
|
|
329
|
+
assert.equal(history.children[1].props.className, 'bt_earlierErrors')
|
|
330
|
+
const latest = history.children[0]
|
|
331
|
+
const classes = latest.children.map((entry) => entry.props?.className ?? '')
|
|
332
|
+
assert.equal(classes[0], 'bt_section bt_network')
|
|
333
|
+
assert.equal(classes[1], 'bt_captureAdvice bt_advice')
|
|
334
|
+
assert.equal(classes[2], 'bt_actions')
|
|
335
|
+
assert.equal(classes[3], 'bt_report')
|
|
336
|
+
assert.equal(classes[4], 'bt_detailPair')
|
|
337
|
+
assert.equal(withClass(latest, 'bt_heading', true).filter((entry) => /已重试|重试记录|失败记录/.test(text(entry))).length, 0)
|
|
338
|
+
assert.equal(withClass(row.tree, 'bt_advice', true).length, 1)
|
|
339
|
+
assert.equal(row.visible.match(/排查/g)?.length, 1)
|
|
340
|
+
assert.doesNotMatch(row.visible, /重试记录 ·|按发生顺序|失败记录 2/)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('a new nonconsecutive error folds the previous card and aborts pending evidence', async () => {
|
|
344
|
+
const old = { key: 'old', kind: 'turn-error', visibility: 'visible', data: terminal().node.data }
|
|
345
|
+
const later = { key: 'later', kind: 'model-retry', visibility: 'visible', data: retry().node.data }
|
|
346
|
+
const answer = { key: 'answer', kind: 'assistant-step', visibility: 'visible', data: {} }
|
|
347
|
+
let nodes = [old, answer]
|
|
348
|
+
const useChat = (selector) => selector({ order: nodes.map((node) => node.key), nodes: new Map(nodes.map((node) => [node.key, node])) })
|
|
349
|
+
const waiting = deferred()
|
|
350
|
+
await renderRow('turn-error', { ...terminal(), node: old, useChat }, { openDetails: false, respond: () => waiting.promise, interact: async ({ row, update, settle }) => {
|
|
351
|
+
assert.equal(row.calls.length, 1)
|
|
352
|
+
nodes = [old, answer, later]
|
|
353
|
+
let next = await update({ ...terminal(), node: old, useChat })
|
|
354
|
+
assert.equal(next.calls[0].options.signal.aborted, true)
|
|
355
|
+
assert.equal(withClass(next.tree, 'bt_earlierNode')[0].props.open, false)
|
|
356
|
+
waiting.resolve(jsonResult(details([socket({ detail: { ...socket().detail, error: { message: 'LATE_OLD_ERROR' } } })])))
|
|
357
|
+
next = await settle()
|
|
358
|
+
assert.doesNotMatch(next.full, /LATE_OLD_ERROR/)
|
|
359
|
+
assert.equal(next.calls.length, 1)
|
|
360
|
+
} })
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('session identity change resets both native and raw disclosures', async () => {
|
|
364
|
+
const props = { ...terminal(), node: { ...terminal().node, key: 'stable', kind: 'turn-error' } }
|
|
365
|
+
await renderRow('turn-error', props, { openDetails: false, response: details([socket()]), interact: async ({ row, toggle, update }) => {
|
|
366
|
+
row = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
367
|
+
row = await toggle(withClass(row.tree, 'bt_networkDetails')[0], true)
|
|
368
|
+
assert.equal(withClass(row.tree, 'bt_disclosure')[0].props.open, true)
|
|
369
|
+
const next = await update({ ...props, sessionId: 'session-other' })
|
|
370
|
+
assert.equal(withClass(next.tree, 'bt_disclosure')[0].props.open, false)
|
|
371
|
+
assert.equal(withClass(next.tree, 'bt_networkDetails')[0].props.open, false)
|
|
372
|
+
assert.ok(next.calls[0].options.signal.aborted)
|
|
373
|
+
} })
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('screenshot HTTP JSON case defaults to capture and opens complete native original in one layer', async () => {
|
|
377
|
+
const body = JSON.stringify({ error: { message: 'unknown provider for model example-model', type: 'invalid_request_error' } })
|
|
378
|
+
const message = `400: ${JSON.stringify({ message: 'unknown provider for model example-model', type: 'invalid_request_error' })}`
|
|
379
|
+
const source = http(400, 'Bad Request', { code: 'INVALID_REQUEST', message }, { detail: { ...http(400, 'Bad Request').detail, body, bodyState: 'complete' } })
|
|
380
|
+
await withClipboard(async (copied) => {
|
|
381
|
+
await renderRow('turn-error', terminal(message, 'INVALID_REQUEST'), { openDetails: false, response: details([source]), interact: async ({ row, toggle, click, beforeDisclosure }) => {
|
|
382
|
+
assert.equal(beforeDisclosure.calls.length, 1)
|
|
383
|
+
assert.equal(withClass(row.tree, 'bt_network')[0].type, 'section')
|
|
384
|
+
assert.match(row.visible, /插件捕获的底层报错.*HTTP 400 Bad Request · unknown provider for model example-model/)
|
|
385
|
+
assert.ok(!row.visible.includes(message))
|
|
386
|
+
assert.ok(!row.visible.includes('gateway.example'))
|
|
387
|
+
assert.ok(!row.visible.includes(body))
|
|
388
|
+
assert.doesNotMatch(row.visible, /摘要 ·|查看完整原文|网络诊断详情/)
|
|
389
|
+
assert.match(row.visible, /底层原始详情|一键复制排障报告/)
|
|
390
|
+
assert.equal(withClass(row.tree, 'bt_fullOriginal').length, 0)
|
|
391
|
+
const raw = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
392
|
+
assert.ok(raw.visible.includes(`[INVALID_REQUEST] ${message}`))
|
|
393
|
+
await click(elements(raw.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
394
|
+
const network = await toggle(withClass(raw.tree, 'bt_networkDetails')[0], true)
|
|
395
|
+
assert.ok(network.visible.includes(body))
|
|
396
|
+
assert.ok(network.visible.includes('gateway.example'))
|
|
397
|
+
} })
|
|
398
|
+
assert.deepEqual(copied, [`[INVALID_REQUEST] ${message}`])
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('short native errors stay folded and copy completely without a summary duplicate', async () => {
|
|
403
|
+
await withClipboard(async (copied) => {
|
|
404
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]), interact: async ({ row, click, toggle }) => {
|
|
405
|
+
assert.equal(withClass(row.tree, 'bt_original').length, 1)
|
|
406
|
+
assert.equal(withClass(row.tree, 'bt_fullOriginal').length, 0)
|
|
407
|
+
assert.doesNotMatch(row.visible, /\[TRANSPORT\] Connection error\./)
|
|
408
|
+
assert.match(row.visible, /插件捕获的底层报错.*ECONNRESET/)
|
|
409
|
+
assert.doesNotMatch(row.visible, /socket wrapper|gateway\.example/)
|
|
410
|
+
const next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
411
|
+
assert.equal(next.visible.split('[TRANSPORT] Connection error.').length - 1, 1)
|
|
412
|
+
await click(elements(next.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
413
|
+
} })
|
|
414
|
+
assert.deepEqual(copied, ['[TRANSPORT] Connection error.'])
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('capture preview stays bounded and marks partial or protected response state without decoding incomplete JSON', async () => {
|
|
419
|
+
for (const state of ['pending', 'truncated', 'unreadable']) {
|
|
420
|
+
const source = http(400, 'Bad Request', failure)
|
|
421
|
+
source.detail = { ...source.detail, body: '{"error":{"message":"' + '😀'.repeat(180), bodyState: state }
|
|
422
|
+
const row = await renderRow('turn-error', terminal(), { response: details([source]) })
|
|
423
|
+
const preview = text(withClass(row.tree, 'bt_primary')[0])
|
|
424
|
+
assert.ok(Array.from(preview).length <= 121)
|
|
425
|
+
assert.ok(preview.includes('{"error"'), 'incomplete JSON remains literal rather than fabricated provider text')
|
|
426
|
+
assert.ok(!preview.includes('\uFFFD'))
|
|
427
|
+
assert.match(row.visible, /响应正文(?:读取中|已截断|读取失败)/)
|
|
428
|
+
}
|
|
429
|
+
const source = http(401, 'Unauthorized', failure)
|
|
430
|
+
source.detail = { ...source.detail, body: 'WITHHELD_BODY', bodyReason: 'credential-protection', protected: true }
|
|
431
|
+
const row = await renderRow('turn-error', terminal(), { response: details([source]) })
|
|
432
|
+
assert.doesNotMatch(text(withClass(row.tree, 'bt_primary')[0]), /WITHHELD_BODY/)
|
|
433
|
+
assert.match(row.visible, /认证响应正文已受保护/)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('default capture copy is complete raw capture while native copy stays exact and report reuses one mounted read', async () => {
|
|
437
|
+
await withClipboard(async (copied) => {
|
|
438
|
+
const capture = http(503, 'Service Unavailable', failure)
|
|
439
|
+
capture.detail.body = JSON.stringify({ error: { message: 'captured provider message\nsecond line', opaque: 'RAW_BODY_TAIL' } })
|
|
440
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload([capture]), interact: async ({ row, click, toggle }) => {
|
|
441
|
+
assert.equal(row.calls.length, 1)
|
|
442
|
+
const captureButton = elements(row.tree, 'button').find((node) => text(node) === '复制截获错误')
|
|
443
|
+
assert.ok(withClass(row.tree, 'bt_copy', true).includes(captureButton), 'capture copy is available without native expansion')
|
|
444
|
+
const expectedCapture = text(withClass(withClass(row.tree, 'bt_networkDetails')[0], 'bt_raw')[0])
|
|
445
|
+
await click(captureButton)
|
|
446
|
+
assert.equal(copied[0], expectedCapture, 'capture copy uses complete raw capture, not bounded preview')
|
|
447
|
+
for (const value of ['HTTP 503 Service Unavailable', 'gateway.example/v1/chat/completions', 'x-request-id: request-debug-id', 'RAW_BODY_TAIL', 'elapsedMs=765432']) assert.ok(copied[0].includes(value), value)
|
|
448
|
+
const reportButton = withClass(row.tree, 'bt_reportCopy', true)[0]
|
|
449
|
+
assert.equal(text(reportButton), '一键复制排障报告')
|
|
450
|
+
const report = await click(reportButton)
|
|
451
|
+
assert.equal(report.calls.length, 1, 'one-click report reuses already fetched evidence')
|
|
452
|
+
assert.match(copied[1], /DSH 原生报错: \[TRANSPORT\] Connection error\./)
|
|
453
|
+
const native = await toggle(withClass(report.tree, 'bt_disclosure')[0], true)
|
|
454
|
+
await click(elements(native.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
455
|
+
assert.equal(copied[2], '[TRANSPORT] Connection error.')
|
|
456
|
+
assert.equal(native.calls.length, 1)
|
|
457
|
+
} })
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('retry default report and capture copy stay inside their own attempt with no duplicate latest controls', async () => {
|
|
462
|
+
const first = { ...retry('first native', 'TRANSPORT').node.data.current, seq: 20, retry: 1, attemptId: 'first' }
|
|
463
|
+
const second = { ...retry('second native', 'SERVER').node.data.current, seq: 30, retry: 2, attemptId: 'second' }
|
|
464
|
+
const a = socket({ failure: first.failure, attemptId: 'first', context: { sessionId: 'session-client-test', provider: 'test-provider', attemptId: 'first' } })
|
|
465
|
+
const b = http(503, 'Second capture', second.failure, { attemptId: 'second', context: { sessionId: 'session-client-test', provider: 'test-provider', attemptId: 'second' } })
|
|
466
|
+
await withClipboard(async (copied) => {
|
|
467
|
+
await renderRow('model-retry', { ...retry(), node: { data: { attempts: [first, second], current: second } } }, { openDetails: false, response: reportPayload([a, b]), interact: async ({ row, click, toggle }) => {
|
|
468
|
+
const attempts = withClass(row.tree, 'bt_attempt').toSorted((a, b) => a.props.key - b.props.key)
|
|
469
|
+
assert.equal(attempts.length, 2)
|
|
470
|
+
assert.equal(withClass(row.tree, 'bt_reportCopy', true).length, 1)
|
|
471
|
+
assert.equal(withClass(row.tree, 'bt_network', true).length, 1)
|
|
472
|
+
assert.equal(withClass(row.tree, 'bt_disclosure').length, 0)
|
|
473
|
+
row = await toggle(withClass(row.tree, 'bt_earlierErrors')[0], true)
|
|
474
|
+
row = await toggle(withClass(row.tree, 'bt_earlierError')[0], true)
|
|
475
|
+
assert.match(row.visible, /ECONNRESET/)
|
|
476
|
+
for (let index = 0; index < attempts.length; index++) {
|
|
477
|
+
assert.equal(withClass(attempts[index], 'bt_reportCopy', true).length, 1)
|
|
478
|
+
assert.equal(withClass(attempts[index], 'bt_attemptNative')[0].props.open, false)
|
|
479
|
+
await click(withClass(attempts[index], 'bt_reportCopy', true)[0])
|
|
480
|
+
}
|
|
481
|
+
assert.match(copied[0], /first native|ECONNRESET/)
|
|
482
|
+
assert.doesNotMatch(copied[0], /second native|Second capture/)
|
|
483
|
+
assert.match(copied[1], /second native|HTTP 503/)
|
|
484
|
+
assert.doesNotMatch(copied[1], /first native|ECONNRESET/)
|
|
485
|
+
assert.equal(row.calls.length, 1)
|
|
486
|
+
} })
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test('consecutive error nodes fold older blocks lazily and reset when a newer error arrives', async () => {
|
|
491
|
+
const node = { key: 'first', kind: 'turn-error', visibility: 'visible', data: terminal().node.data }
|
|
492
|
+
const later = { key: 'second', kind: 'turn-error', visibility: 'visible', data: terminal('later failure').node.data }
|
|
493
|
+
const tail = { key: 'tail', kind: 'turn-tail', visibility: 'visible', data: {} }
|
|
494
|
+
let ordered = [node, tail, later]
|
|
495
|
+
const useChat = (selector) => selector({ order: ordered.map((entry) => entry.key), nodes: { get: (key) => ordered.find((entry) => entry.key === key) } })
|
|
496
|
+
await renderRow('turn-error', { ...terminal(), node, useChat }, { openDetails: false, response: details([socket()]), interact: async ({ row, toggle, update }) => {
|
|
497
|
+
assert.equal(row.calls.length, 0, 'folded older node does not fetch capture until revealed')
|
|
498
|
+
assert.match(row.visible, /更早的报错/)
|
|
499
|
+
assert.doesNotMatch(row.visible, /ECONNRESET|一键复制排障报告/)
|
|
500
|
+
let next = await toggle(withClass(row.tree, 'bt_earlierNode')[0], true)
|
|
501
|
+
assert.equal(next.calls.length, 1)
|
|
502
|
+
assert.match(next.visible, /ECONNRESET/)
|
|
503
|
+
ordered = [node, { ...later, key: 'third' }]
|
|
504
|
+
next = await update({ ...terminal(), node, useChat })
|
|
505
|
+
assert.equal(withClass(next.tree, 'bt_earlierNode')[0].props.open, false, 'new successor resets old manual expansion')
|
|
506
|
+
assert.doesNotMatch(next.visible, /ECONNRESET/)
|
|
507
|
+
ordered = [node, { key: 'answer', kind: 'assistant-step', visibility: 'visible', data: {} }, later]
|
|
508
|
+
next = await update({ ...terminal(), node, useChat })
|
|
509
|
+
assert.equal(withClass(next.tree, 'bt_earlierNode').length, 1, 'an intervening reply does not remove historical folding')
|
|
510
|
+
assert.match(next.visible, /ECONNRESET/, 'manual expansion survives while the newest error identity is unchanged')
|
|
511
|
+
} })
|
|
512
|
+
for (const boundary of [
|
|
513
|
+
{ key: 'user', kind: 'user', visibility: 'visible' },
|
|
514
|
+
{ key: 'tool', kind: 'tool-call', visibility: 'visible' },
|
|
515
|
+
{ key: 'success', kind: 'manual-compaction', visibility: 'visible', data: { compaction: {} } },
|
|
516
|
+
{ key: 'missing' },
|
|
517
|
+
]) {
|
|
518
|
+
const useBoundary = (selector) => selector({ order: [node.key, boundary.key, later.key], nodes: { get: (key) => key === boundary.key ? boundary.kind ? boundary : undefined : key === node.key ? node : later } })
|
|
519
|
+
const separated = await renderRow('turn-error', { ...terminal(), node, useChat: useBoundary }, { openDetails: false })
|
|
520
|
+
assert.equal(withClass(separated.tree, 'bt_earlierNode').length, boundary.kind ? 1 : 0, boundary.key)
|
|
521
|
+
}
|
|
522
|
+
ordered = [node, later]
|
|
523
|
+
const latest = await renderRow('turn-error', { ...terminal('later failure'), node: later, useChat }, { openDetails: false })
|
|
524
|
+
assert.equal(withClass(latest.tree, 'bt_earlierNode').length, 0)
|
|
525
|
+
assert.equal(latest.calls.length, 1, 'latest error keeps normal capture behavior')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test('earlier retries fold by stable record identity while newest record stays visible', async () => {
|
|
529
|
+
const attempts = [1, 2, 3].map((retryNumber) => ({ ...retry('native-' + retryNumber).node.data.current, seq: retryNumber * 10, retry: retryNumber, attemptId: 'a' + retryNumber }))
|
|
530
|
+
const captures = attempts.map((attempt, index) => http(503, 'capture-' + (index + 1), attempt.failure, { attemptId: attempt.attemptId, context: { sessionId: 'session-client-test', provider: 'test-provider', attemptId: attempt.attemptId } }))
|
|
531
|
+
const props = { ...retry(), node: { data: { attempts, current: attempts.at(-1) } } }
|
|
532
|
+
await renderRow('model-retry', props, { openDetails: false, response: details(captures), interact: async ({ row, toggle, update }) => {
|
|
533
|
+
assert.match(row.visible, /capture-3/)
|
|
534
|
+
assert.doesNotMatch(row.visible, /capture-1|capture-2/)
|
|
535
|
+
assert.match(row.visible, /更早的报错 · 2/)
|
|
536
|
+
let next = await toggle(withClass(row.tree, 'bt_earlierErrors')[0], true)
|
|
537
|
+
assert.doesNotMatch(next.visible, /capture-1|capture-2/)
|
|
538
|
+
next = await toggle(withClass(next.tree, 'bt_earlierError')[0], true)
|
|
539
|
+
assert.match(next.visible, /capture-1/)
|
|
540
|
+
assert.doesNotMatch(next.visible, /capture-2/)
|
|
541
|
+
const current = { ...attempts.at(-1), seq: 40, retry: 4, attemptId: 'a4' }
|
|
542
|
+
next = await update({ ...props, node: { data: { attempts: [...attempts, current], current } } })
|
|
543
|
+
assert.equal(withClass(next.tree, 'bt_earlierErrors')[0].props.open, false)
|
|
544
|
+
assert.doesNotMatch(next.visible, /capture-1|capture-2|capture-3/)
|
|
545
|
+
assert.equal(withClass(next.tree, 'bt_attempt').filter((block) => block.type === 'div').length, 4, 'no errors are discarded or deduplicated')
|
|
546
|
+
} })
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test('report reads complete enabled-plugin environment on every click without cached inventory or private fields', async () => {
|
|
550
|
+
const names = Array.from({ length: 125 }, (_, index) => ({ name: 'enabled-plugin-' + index, version: '1.2.' + index }))
|
|
551
|
+
await withClipboard(async (copied) => {
|
|
552
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), environmentRespond: async (_options, count) => jsonResult({ runtime: {
|
|
553
|
+
dshVersion: '0.2.2-rc.2', pluginVersion: '0.12.0', pluginsStatus: 'complete',
|
|
554
|
+
enabledPlugins: [...names, names[0], { name: 'new-plugin', version: count === 1 ? '1.0.0' : '2.0.0', config: 'PRIVATE_CONFIG', path: 'PRIVATE_PATH', source: 'PRIVATE_SOURCE' }],
|
|
555
|
+
} }), interact: async ({ row, click }) => {
|
|
556
|
+
assert.equal(row.environmentCalls.length, 0, 'no inventory request before manual report generation')
|
|
557
|
+
let next = await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
558
|
+
assert.equal(next.environmentCalls.length, 1)
|
|
559
|
+
const line = copied[0].split('\n').find((line) => line.startsWith('已启用第三方插件: '))
|
|
560
|
+
for (const plugin of names) assert.ok(line.includes(plugin.name + '@' + plugin.version))
|
|
561
|
+
assert.equal(line.split('enabled-plugin-0@').length - 1, 1)
|
|
562
|
+
assert.match(line, /new-plugin@1\.0\.0/)
|
|
563
|
+
assert.doesNotMatch(copied[0], /PRIVATE_CONFIG|PRIVATE_PATH|PRIVATE_SOURCE/)
|
|
564
|
+
next = await click(withClass(next.tree, 'bt_reportCopy')[0])
|
|
565
|
+
assert.equal(next.environmentCalls.length, 2, 'inventory is not reused from cached evidence')
|
|
566
|
+
assert.match(copied[1], /new-plugin@2\.0\.0/)
|
|
567
|
+
assert.doesNotMatch(copied[1], /new-plugin@1\.0\.0/)
|
|
568
|
+
assert.equal(next.calls.length, 1, 'network evidence still reuses its completed cache')
|
|
569
|
+
} })
|
|
570
|
+
})
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
test('unavailable or partial plugin inventory remains explicit rather than claiming an empty list', async () => {
|
|
574
|
+
for (const [environmentRespond, marker] of [
|
|
575
|
+
[async () => { throw Error('SECRET_ENV_ERROR') }, '清单读取失败或不可用'],
|
|
576
|
+
[async () => jsonResult({ runtime: { enabledPlugins: [{ name: 'known-plugin', version: null }], pluginsStatus: 'partial' } }), '部分插件信息未能确认'],
|
|
577
|
+
[async () => jsonResult({ runtime: { enabledPlugins: [], pluginsStatus: 'complete' } }), '已启用第三方插件: 无'],
|
|
578
|
+
]) await withClipboard(async (copied) => {
|
|
579
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), environmentRespond, interact: async ({ row, click }) => {
|
|
580
|
+
await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
581
|
+
assert.ok(copied[0].includes(marker))
|
|
582
|
+
assert.doesNotMatch(copied[0], /SECRET_ENV_ERROR/)
|
|
583
|
+
} })
|
|
584
|
+
})
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
test('changing identity aborts inventory read and cannot copy old report', async () => {
|
|
588
|
+
const pending = deferred()
|
|
589
|
+
await withClipboard(async (copied) => {
|
|
590
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), environmentRespond: () => pending.promise, interact: async ({ row, update, settle }) => {
|
|
591
|
+
const task = withClass(row.tree, 'bt_reportCopy')[0].props.onClick()
|
|
592
|
+
let next = await settle()
|
|
593
|
+
assert.equal(next.environmentCalls.length, 1)
|
|
594
|
+
next = await update({ ...terminal(), sessionId: 'new-report-session' })
|
|
595
|
+
assert.equal(next.environmentCalls[0].options.signal.aborted, true)
|
|
596
|
+
pending.resolve(jsonResult({ runtime: { enabledPlugins: [{ name: 'OLD_ENV', version: '1' }], pluginsStatus: 'complete' } }))
|
|
597
|
+
await task
|
|
598
|
+
assert.equal(copied.length, 0)
|
|
599
|
+
} })
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
|
|
280
603
|
test('only five inline renderers and the automatic-compaction definition are registered', () => {
|
|
281
604
|
assert.deepEqual([...client.module.inject].sort(), ['locale', 'slots', 'uiConversation'])
|
|
282
605
|
assert.deepEqual(client.slots.map((entry) => entry.options.key).sort(), ['better-tips-compaction', 'compaction', 'manual-compaction', 'model-retry', 'turn-error'])
|
|
@@ -300,31 +623,29 @@ test('unloading restores shipped cells without same-priority collisions', () =>
|
|
|
300
623
|
assert.ok([...mounted.occupied.values()].every((entry) => entry.shipped))
|
|
301
624
|
})
|
|
302
625
|
|
|
303
|
-
test('default
|
|
304
|
-
const row = await renderRow('turn-error', terminal(), { response: details([socket()]) })
|
|
305
|
-
assert.
|
|
306
|
-
assert.
|
|
307
|
-
assert.match(row.primary, /Connection error\./)
|
|
308
|
-
assert.match(row.primary, /TRANSPORT/)
|
|
309
|
-
assert.doesNotMatch(row.primary, /fetch failed|socket wrapper/)
|
|
626
|
+
test('default capture preserves deepest code while complete evidence and native original stay independently folded', async () => {
|
|
627
|
+
const row = await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]) })
|
|
628
|
+
assert.match(row.primary, /ECONNRESET.*read ECONNRESET/)
|
|
629
|
+
assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT|fetch failed|socket wrapper/)
|
|
310
630
|
assertQuiet(row.primary)
|
|
311
631
|
for (const value of ['Connection error.', 'TRANSPORT', 'fetch failed', 'socket wrapper', 'ECONNRESET', '10.0.0.1', '443', 'gateway.example', '/v1/chat/completions']) assert.ok(row.full.includes(value), `raw details lost ${value}`)
|
|
312
|
-
assert.equal(row.beforeDisclosure.calls.length,
|
|
632
|
+
assert.equal(row.beforeDisclosure.calls.length, 1)
|
|
313
633
|
assert.ok(elements(row.beforeDisclosure.tree, 'details').every((node) => !node.props.open))
|
|
314
634
|
assert.match(row.calls[0].url, /^api\/better-tips\/details\?/)
|
|
315
635
|
})
|
|
316
636
|
|
|
317
|
-
test('
|
|
637
|
+
test('native multiline original is not excerpted and opens in one native disclosure', async () => {
|
|
318
638
|
const original = 'Connection error.\nprovider diagnostic: keep this line'
|
|
319
639
|
const capture = socket({ failure: { code: 'TRANSPORT', message: original } })
|
|
320
640
|
for (const response of [details([]), details([capture])]) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
641
|
+
await renderRow('turn-error', terminal(original), { openDetails: false, response, interact: async ({ row, toggle }) => {
|
|
642
|
+
assertOriginal(row, { code: 'TRANSPORT', message: original })
|
|
643
|
+
assert.equal(withClass(row.tree, 'bt_preview').length, 0)
|
|
644
|
+
assert.doesNotMatch(row.visible, /provider diagnostic|摘要 ·/)
|
|
645
|
+
const next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
646
|
+
assert.ok(next.visible.includes(original))
|
|
647
|
+
assert.equal(next.calls.length, 1)
|
|
648
|
+
} })
|
|
328
649
|
}
|
|
329
650
|
})
|
|
330
651
|
|
|
@@ -332,10 +653,10 @@ test('HTTP status/statusText is primary; headers, body, endpoint and timing are
|
|
|
332
653
|
const raw = { message: '504 status code (no body)', code: 'SERVER' }
|
|
333
654
|
const row = await renderRow('turn-error', terminal(raw.message, raw.code), { response: details([http(504, 'Gateway Timeout', raw)]) })
|
|
334
655
|
assert.match(text(withClass(row.tree, 'bt_primary')[0]), /HTTP\s*504.*Gateway Timeout/)
|
|
335
|
-
assert.
|
|
656
|
+
assert.match(row.primary, /Gateway Timeout/)
|
|
336
657
|
assertQuiet(row.primary)
|
|
337
658
|
for (const value of ['Caddy', 'request-debug-id', 'raw-response-504', 'provider detail', 'gateway.example', '/v1/chat/completions', raw.message]) assert.ok(row.full.includes(value), `HTTP evidence lost ${value}`)
|
|
338
|
-
assert.
|
|
659
|
+
assert.match(row.primary, /raw-response-504/); assert.doesNotMatch(row.primary, /provider detail/)
|
|
339
660
|
})
|
|
340
661
|
|
|
341
662
|
test('auth fallback preserves raw provider code/message and adds separate category advice without guessing quota', async () => {
|
|
@@ -358,7 +679,7 @@ test('exact matching includes step and original failure, never a neighboring att
|
|
|
358
679
|
assert.match(text(withClass(second.tree, 'bt_primary')), /HTTP\s*431/)
|
|
359
680
|
assert.doesNotMatch(text(withClass(second.tree, 'bt_primary')), /504/)
|
|
360
681
|
const other = await renderRow('turn-error', terminal(), { response: details([socket({ step: 8 })]) })
|
|
361
|
-
assert.match(other.primary,
|
|
682
|
+
assert.match(other.primary, /未取得额外网络诊断/)
|
|
362
683
|
assert.doesNotMatch(other.primary, /ECONNRESET/)
|
|
363
684
|
})
|
|
364
685
|
|
|
@@ -376,8 +697,8 @@ test('broken SSE preserves its deepest native cause primary and stream metadata
|
|
|
376
697
|
capture.detail = { ...capture.detail, kind: 'stream', status: 200, bytes: 54321 }
|
|
377
698
|
const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
|
|
378
699
|
assert.match(text(withClass(row.tree, 'bt_primary')), /ECONNRESET/)
|
|
379
|
-
assert.
|
|
380
|
-
assert.
|
|
700
|
+
assert.match(row.primary, /ECONNRESET/)
|
|
701
|
+
assert.doesNotMatch(row.primary, /54321|fetch failed|socket wrapper|Connection error\./)
|
|
381
702
|
assertQuiet(row.primary)
|
|
382
703
|
assert.match(row.full, /54321/)
|
|
383
704
|
assert.match(row.full, /fetch failed/)
|
|
@@ -392,7 +713,7 @@ test('one non-exact capture has only the short unconfirmed marker, never specula
|
|
|
392
713
|
const row = await renderRow('model-retry', retry(), { response: details([http(499, 'Client Closed Request')]) })
|
|
393
714
|
assert.match(row.full, /HTTP\s*499/)
|
|
394
715
|
assert.match(row.full, /关联未确认/)
|
|
395
|
-
assert.
|
|
716
|
+
assert.match(row.primary, /HTTP\s*499.*关联未确认|关联未确认.*HTTP\s*499/)
|
|
396
717
|
assertQuiet(row.primary)
|
|
397
718
|
assert.doesNotMatch(row.full, /可能是同一次|例如我们|只有这一条捕获|与本行的.*不同|无法证实/)
|
|
398
719
|
assert.match(row.full, /Connection error\./)
|
|
@@ -401,9 +722,8 @@ test('one non-exact capture has only the short unconfirmed marker, never specula
|
|
|
401
722
|
|
|
402
723
|
test('multiple unmatched captures stay candidates in details; raw row failure stays primary', async () => {
|
|
403
724
|
const row = await renderRow('model-retry', retry(), { response: details([http(499, 'Client Closed Request'), http(431, 'Request Header Fields Too Large')]) })
|
|
404
|
-
assert.
|
|
405
|
-
assert.match(row.primary,
|
|
406
|
-
assert.doesNotMatch(row.primary, /HTTP\s*(499|431)|关联未确认|候选|不一致|捕获/)
|
|
725
|
+
assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT|HTTP\s*(499|431)/)
|
|
726
|
+
assert.match(row.primary, /候选捕获/)
|
|
407
727
|
assert.match(row.full, /499/)
|
|
408
728
|
assert.match(row.full, /431/)
|
|
409
729
|
assert.match(row.full, /候选|另一次|candidate/i)
|
|
@@ -418,12 +738,11 @@ test('loading, empty capture and API failure never displace the primary error',
|
|
|
418
738
|
{ key: 'ui.loadFailed', options: { error: new Error('offline-test-marker') } },
|
|
419
739
|
]) {
|
|
420
740
|
const row = await renderRow('turn-error', terminal(), options)
|
|
421
|
-
assert.
|
|
422
|
-
|
|
423
|
-
assertQuiet(row.primary)
|
|
741
|
+
assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT/)
|
|
742
|
+
assertOriginal(row, failure)
|
|
424
743
|
const marker = client.t(key, { message: '', error: '' }).replace(/\{[^{}]+\}/g, '').trim()
|
|
425
744
|
assert.ok(row.full.includes(marker), `${key} should be available in details`)
|
|
426
|
-
assert.equal(row.primary.includes(marker),
|
|
745
|
+
assert.equal(row.primary.includes(marker), true, `${key} is honestly visible without fabricated capture`)
|
|
427
746
|
if (options.status) assert.match(row.full, /HTTP\s*401/)
|
|
428
747
|
if (options.error) assert.match(row.full, /offline-test-marker/)
|
|
429
748
|
}
|
|
@@ -435,10 +754,8 @@ test('retry summary includes state/count/delay and the same native primary', asy
|
|
|
435
754
|
assert.match(row.primary, /重试/)
|
|
436
755
|
assert.match(row.primary, /2\s*\/\s*5/)
|
|
437
756
|
assert.match(row.primary, /987\s*(?:ms|毫秒)|0\.987\s*s/)
|
|
438
|
-
assert.
|
|
439
|
-
assert.
|
|
440
|
-
assert.match(row.primary, /Connection error\./)
|
|
441
|
-
assert.match(row.primary, /TRANSPORT/)
|
|
757
|
+
assert.match(row.primary, /ECONNRESET/)
|
|
758
|
+
assert.doesNotMatch(row.primary, /Connection error\.|TRANSPORT/)
|
|
442
759
|
assertOriginal(row, failure)
|
|
443
760
|
assertMainAdvice(row, '检查代理与上游连接')
|
|
444
761
|
assertQuiet(row.primary)
|
|
@@ -457,20 +774,20 @@ const manualFailure = {
|
|
|
457
774
|
}
|
|
458
775
|
const autoFailure = {
|
|
459
776
|
sessionId: 'session-client-compact-auto',
|
|
460
|
-
node: { data: { compactionId: 'compact-1', startedAt: 1000, endedAt: 4000, endSeq: 8, error: '
|
|
777
|
+
node: { data: { compactionId: 'compact-1', startedAt: 1000, endedAt: 4000, endSeq: 8, error: 'summarization produced no text summary content' } },
|
|
461
778
|
}
|
|
462
779
|
|
|
463
780
|
test('manual/auto compaction failures use the same native primary; metadata stays expanded', async () => {
|
|
464
781
|
for (const [key, props] of [['manual-compaction', manualFailure], ['better-tips-compaction', autoFailure]]) {
|
|
465
782
|
const row = await renderRow(key, props, { response: compactions() })
|
|
466
783
|
assert.match(row.primary, /压缩|compact/i)
|
|
467
|
-
assert.
|
|
784
|
+
assert.match(row.primary, /ECONNRESET|read ECONNRESET/)
|
|
468
785
|
assert.match(row.full, /ECONNRESET|read ECONNRESET/)
|
|
469
786
|
assert.equal(withClass(row.tree, 'bt_compactionHistory').length, 1)
|
|
470
787
|
assert.equal(withClass(row.tree, 'bt_attempt').length, 2)
|
|
471
788
|
assert.doesNotMatch(row.primary, /provider-debug|model-debug|次尝试|等待|重试/)
|
|
472
789
|
const original = key === 'manual-compaction' ? manualFailure.node.data.command.outcome.text : autoFailure.node.data.error
|
|
473
|
-
assert.ok(row.primary.includes(original), '
|
|
790
|
+
assert.ok(!row.primary.includes(original), 'native compaction error stays in its disclosure')
|
|
474
791
|
assertOriginal(row, { message: original })
|
|
475
792
|
assertMainAdvice(row, '展开错误详情,核对请求地址与服务端日志。')
|
|
476
793
|
assertCaptureAdvice(row, '检查代理与上游连接')
|
|
@@ -481,17 +798,183 @@ test('manual/auto compaction failures use the same native primary; metadata stay
|
|
|
481
798
|
}
|
|
482
799
|
})
|
|
483
800
|
|
|
801
|
+
test('manual compaction replaces generic notice with exact durable transaction failure in display copy and report', async () => {
|
|
802
|
+
const concrete = 'summary is not smaller than the shadowed content (900 estimated framed tokens >= 800)'
|
|
803
|
+
const source = sessionFixture([
|
|
804
|
+
logEvent(1, 'command/run', { commandId: 'cmd-1', name: 'compact' }),
|
|
805
|
+
logEvent(2, 'compaction/start', { sourceCommandId: 'cmd-1', compactionId: 'compact-exact' }),
|
|
806
|
+
logEvent(3, 'compaction/end', { sourceCommandId: 'cmd-OTHER', compactionId: 'other', error: 'FOREIGN_ERROR' }),
|
|
807
|
+
logEvent(4, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: 'compact-exact', error: concrete }),
|
|
808
|
+
logEvent(5, 'command/done', { commandId: 'cmd-1', kind: 'error', text: manualFailure.node.data.command.outcome.text }),
|
|
809
|
+
])
|
|
810
|
+
await withClipboard(async (copied) => {
|
|
811
|
+
await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: source.service, response: { ...compactions([]), runtime: reportPayload().runtime }, interact: async ({ row, click }) => {
|
|
812
|
+
assert.ok(row.visible.includes(concrete))
|
|
813
|
+
assert.ok(!row.visible.includes(manualFailure.node.data.command.outcome.text))
|
|
814
|
+
assert.doesNotMatch(row.full, /FOREIGN_ERROR/)
|
|
815
|
+
await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
816
|
+
await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
817
|
+
assert.equal(copied[0], concrete)
|
|
818
|
+
assert.ok(copied[1].includes(concrete))
|
|
819
|
+
assert.ok(!copied[1].includes(manualFailure.node.data.command.outcome.text))
|
|
820
|
+
} })
|
|
821
|
+
})
|
|
822
|
+
assert.equal(source.observers.size, 0)
|
|
823
|
+
assert.equal(source.stats.subscriptions, source.stats.cleanups)
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
test('manual compaction rejects ambiguous transient and foreign transactions and updates on exact arrival', async () => {
|
|
827
|
+
const event = (seq, id, error) => logEvent(seq, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: id, error })
|
|
828
|
+
for (const events of [
|
|
829
|
+
[event(3, 'a', 'AMBIGUOUS_A'), event(4, 'b', 'AMBIGUOUS_B')],
|
|
830
|
+
[{ type: 'transient', event: event(3, 'a', 'TRANSIENT_ERROR') }],
|
|
831
|
+
[logEvent(3, 'compaction/end', { sourceCommandId: 'cmd-other', compactionId: 'a', error: 'FOREIGN_ERROR' })],
|
|
832
|
+
]) {
|
|
833
|
+
const source = sessionFixture(events)
|
|
834
|
+
const row = await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: source.service, response: compactions([]) })
|
|
835
|
+
assert.ok(row.visible.includes(manualFailure.node.data.command.outcome.text))
|
|
836
|
+
assert.match(row.visible, /具体失败记录尚未加载/)
|
|
837
|
+
assert.doesNotMatch(row.full, /AMBIGUOUS_|TRANSIENT_ERROR|FOREIGN_ERROR/)
|
|
838
|
+
}
|
|
839
|
+
const source = sessionFixture([])
|
|
840
|
+
await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: source.service, response: compactions([]), interact: async ({ row, settle, update }) => {
|
|
841
|
+
source.publish([event(4, 'exact', 'summarization produced no text summary content')])
|
|
842
|
+
let next = await settle()
|
|
843
|
+
assert.match(next.visible, /summarization produced no text summary content/)
|
|
844
|
+
const changed = structuredClone(manualFailure)
|
|
845
|
+
changed.sessionId = 'session-new'
|
|
846
|
+
changed.node.data.command.commandId = 'cmd-new'
|
|
847
|
+
next = await update(changed)
|
|
848
|
+
assert.doesNotMatch(next.full, /summarization produced no text summary content/)
|
|
849
|
+
source.publish([event(4, 'exact', 'OLD_IDENTITY_LEAK')])
|
|
850
|
+
next = await settle()
|
|
851
|
+
assert.doesNotMatch(next.full, /OLD_IDENTITY_LEAK/)
|
|
852
|
+
} })
|
|
853
|
+
assert.equal(source.observers.size, 0)
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
test('loaded compaction failure survives pagination error and summary failure remains separate from native notice', async () => {
|
|
857
|
+
const concrete = 'summarization produced no text summary content'
|
|
858
|
+
const event = logEvent(4, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: 'exact', error: concrete })
|
|
859
|
+
const row = await renderRow('manual-compaction', manualFailure, { openDetails: false,
|
|
860
|
+
sessions: sessionFixture([event], { loadError: Error('fixture pagination failed') }).service, response: compactions([]) })
|
|
861
|
+
assert.ok(row.visible.includes(concrete))
|
|
862
|
+
const record = { ...compactFailureRecord, attempts: [{ failure: { code: 'TRANSPORT', message: 'Stream ended without finish_reason' } }] }
|
|
863
|
+
await withClipboard(async (copied) => {
|
|
864
|
+
await renderRow('manual-compaction', manualFailure, { openDetails: false, response: { ...compactions([record]), runtime: reportPayload().runtime }, interact: async ({ row, click }) => {
|
|
865
|
+
assert.match(row.visible, /摘要请求报错 · 关联未确认.*Stream ended without finish_reason/)
|
|
866
|
+
assert.ok(row.visible.includes(manualFailure.node.data.command.outcome.text), 'separate native command notice remains honest')
|
|
867
|
+
await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
868
|
+
assert.match(copied[0], /摘要请求报错: \[TRANSPORT\] Stream ended without finish_reason/)
|
|
869
|
+
} })
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
test('manual durable failure hides AUTH and filters common credentials without changing source event', async () => {
|
|
874
|
+
for (const message of ['AUTH: ARBITRARY_PRIVATE_ERROR', 'unrecognized-auth-value', 'provider failure sk-credentialSecret123456789']) {
|
|
875
|
+
const event = logEvent(4, 'compaction/end', { sourceCommandId: 'cmd-1', compactionId: 'exact', error: message })
|
|
876
|
+
const row = await renderRow('manual-compaction', manualFailure, { openDetails: false, sessions: sessionFixture([event]).service, response: compactions([]) })
|
|
877
|
+
assert.doesNotMatch(row.full, /ARBITRARY_PRIVATE_ERROR|unrecognized-auth-value|credentialSecret123456789/)
|
|
878
|
+
assert.equal(event.data.error, message)
|
|
879
|
+
}
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
test('automatic durable compaction text is protected in fallback, report, target log and neighbor log', async () => {
|
|
883
|
+
for (const message of ['AUTH: ARBITRARY_AUTO_AUTH_VALUE', 'unrecognized-auto-credential-value', 'provider failure sk-credentialSecret123456789']) {
|
|
884
|
+
const props = { ...autoFailure, node: { data: { ...autoFailure.node.data, error: message } } }
|
|
885
|
+
const event = logEvent(8, 'compaction/end', { compactionId: 'compact-1', error: message })
|
|
886
|
+
const source = sessionFixture([event, logEvent(9, 'turn/end', { turn: 9, step: 3, error: failure })])
|
|
887
|
+
await withClipboard(async (copied) => {
|
|
888
|
+
const row = await renderRow('better-tips-compaction', props, { response: compactions([]), sessions: source.service, clickLog: true, interact: async ({ row, click }) => {
|
|
889
|
+
assert.doesNotMatch(row.full, /ARBITRARY_AUTO_AUTH_VALUE|unrecognized-auto-credential-value|credentialSecret123456789/)
|
|
890
|
+
assert.match(row.full, /正文已隐藏|认证错误正文已隐藏/)
|
|
891
|
+
assert.match(text(withClass(row.tree, 'bt_logJson')[0]), /compaction diagnostic hidden/)
|
|
892
|
+
await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
893
|
+
assert.doesNotMatch(copied[0], /ARBITRARY_AUTO_AUTH_VALUE|unrecognized-auto-credential-value|credentialSecret123456789/)
|
|
894
|
+
} })
|
|
895
|
+
assert.equal(event.data.error, message, 'durable event is never changed')
|
|
896
|
+
assert.ok(row.full.includes('compaction diagnostic hidden'))
|
|
897
|
+
})
|
|
898
|
+
const neighboring = await renderRow('turn-error', terminalLog(9), { sessions: source.service, clickLog: true })
|
|
899
|
+
assert.doesNotMatch(neighboring.full, /ARBITRARY_AUTO_AUTH_VALUE|unrecognized-auto-credential-value|credentialSecret123456789/)
|
|
900
|
+
}
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
test('compaction selects final max-tokens or incomplete attempt without reviving an earlier network failure', async () => {
|
|
904
|
+
for (const [status, finishReason] of [['max-tokens', 'max-tokens'], ['incomplete', 'unexpected-kind']]) {
|
|
905
|
+
const earlier = { index: 1, attemptId: 'older-summary', finishReason: 'error', failure: { code: 'SERVER', message: 'EARLIER_SUMMARY_FAILURE' }, detail: http(502, 'EARLIER_CAPTURE').detail }
|
|
906
|
+
const latest = { index: 2, attemptId: 'latest-summary', finishReason }
|
|
907
|
+
const record = { ...compactFailureRecord, status, attempts: [earlier, latest] }
|
|
908
|
+
await withClipboard(async (copied) => {
|
|
909
|
+
await renderRow('manual-compaction', manualFailure, { openDetails: false, response: compactions([record]), interact: async ({ row, click, toggle }) => {
|
|
910
|
+
assert.doesNotMatch(row.visible, /EARLIER_SUMMARY_FAILURE|EARLIER_CAPTURE|HTTP 502/)
|
|
911
|
+
assert.ok(row.visible.includes('finish=' + finishReason))
|
|
912
|
+
assert.equal(withClass(row.tree, 'bt_nativeFallback').length, 1)
|
|
913
|
+
await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
914
|
+
assert.doesNotMatch(copied[0], /EARLIER_SUMMARY_FAILURE|EARLIER_CAPTURE|HTTP 502/)
|
|
915
|
+
assert.ok(copied[0].includes('finish=' + finishReason))
|
|
916
|
+
assert.match(copied[0], /未取得唯一匹配的底层证据/)
|
|
917
|
+
const expanded = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
918
|
+
assert.ok(expanded.full.includes('EARLIER_SUMMARY_FAILURE'), 'earlier evidence remains in history')
|
|
919
|
+
assert.ok(expanded.full.includes('EARLIER_CAPTURE'))
|
|
920
|
+
assert.doesNotMatch(expanded.visible, /EARLIER_SUMMARY_FAILURE|EARLIER_CAPTURE|HTTP 502/)
|
|
921
|
+
} })
|
|
922
|
+
})
|
|
923
|
+
}
|
|
924
|
+
})
|
|
925
|
+
|
|
484
926
|
test('compaction without capture falls back to raw error, with evidence status only in details', async () => {
|
|
485
|
-
const auto = await renderRow('better-tips-compaction', { ...autoFailure, node: { data: { ...autoFailure.node.data, error: '
|
|
486
|
-
assert.match(auto.primary,
|
|
927
|
+
const auto = await renderRow('better-tips-compaction', { ...autoFailure, node: { data: { ...autoFailure.node.data, error: 'summarization truncated at the token cap (incomplete checkpoint)' } } }, { response: compactions([]) })
|
|
928
|
+
assert.match(auto.primary, /未取得额外网络诊断/)
|
|
929
|
+
assert.match(auto.primary, /MAX_TOKENS/)
|
|
930
|
+
assert.equal(withClass(auto.tree, 'bt_nativeFallback').length, 1)
|
|
931
|
+
assert.equal(withClass(auto.tree, 'bt_network', true).length, 0)
|
|
932
|
+
assertOriginal(auto, { code: 'MAX_TOKENS', message: 'summarization truncated at the token cap (incomplete checkpoint)' })
|
|
487
933
|
assertMainAdvice(auto, '摘要输出 token 上限')
|
|
488
934
|
assertQuiet(auto.primary)
|
|
489
935
|
const manual = await renderRow('manual-compaction', manualFailure, { response: compactions([]) })
|
|
490
|
-
assert.match(manual.primary,
|
|
936
|
+
assert.match(manual.primary, /未取得额外网络诊断/)
|
|
937
|
+
assert.ok(manual.primary.includes(manualFailure.node.data.command.outcome.text))
|
|
938
|
+
assertOriginal(manual, { message: manualFailure.node.data.command.outcome.text })
|
|
491
939
|
assertMainAdvice(manual, '展开错误详情,核对请求地址与服务端日志。')
|
|
492
940
|
assertQuiet(manual.primary)
|
|
493
941
|
})
|
|
494
942
|
|
|
943
|
+
test('compaction native-only failures stay visible and source-labelled through missing capture states', async () => {
|
|
944
|
+
for (const options of [
|
|
945
|
+
{ pending: true },
|
|
946
|
+
{ response: compactions([]) },
|
|
947
|
+
{ response: compactions([{ ...compactFailureRecord, attempts: [{ failure }] }]) },
|
|
948
|
+
{ response: { records: [], capture: { installed: false, reason: 'config-disabled' } } },
|
|
949
|
+
{ response: { records: [], capture: { installed: false, reason: 'unavailable' } } },
|
|
950
|
+
{ status: 503 },
|
|
951
|
+
]) {
|
|
952
|
+
for (const [key, props, message] of [
|
|
953
|
+
['manual-compaction', manualFailure, manualFailure.node.data.command.outcome.text],
|
|
954
|
+
['better-tips-compaction', autoFailure, autoFailure.node.data.error],
|
|
955
|
+
]) {
|
|
956
|
+
const row = await renderRow(key, props, { ...options, openDetails: false })
|
|
957
|
+
assert.ok(row.visible.includes(message))
|
|
958
|
+
const native = withClass(row.tree, 'bt_nativeFallback')[0]
|
|
959
|
+
assert.ok(native)
|
|
960
|
+
assert.match(text(native), /DSH 原生报错/)
|
|
961
|
+
assert.equal(withClass(row.tree, 'bt_original').filter((node) => text(node) === message).length, 1, 'native failure is not duplicated in the log disclosure')
|
|
962
|
+
assert.equal(withClass(row.tree, 'bt_network', true).length, 0, 'native text never masquerades as plugin capture')
|
|
963
|
+
assert.equal(withClass(row.tree, 'bt_reportCopy', true).length, 1)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
const waiting = deferred()
|
|
967
|
+
await renderRow('better-tips-compaction', autoFailure, { openDetails: false, respond: () => waiting.promise, interact: async ({ row, settle }) => {
|
|
968
|
+
assert.ok(row.visible.includes(autoFailure.node.data.error))
|
|
969
|
+
waiting.resolve(jsonResult(compactions()))
|
|
970
|
+
const ready = await settle()
|
|
971
|
+
assert.equal(withClass(ready.tree, 'bt_nativeFallback').length, 0)
|
|
972
|
+
assert.match(ready.visible, /插件捕获的底层报错.*ECONNRESET/)
|
|
973
|
+
assert.ok(!ready.visible.includes(autoFailure.node.data.error), 'captured evidence replaces fallback, native source remains folded')
|
|
974
|
+
assert.equal(ready.calls.length, 1)
|
|
975
|
+
} })
|
|
976
|
+
})
|
|
977
|
+
|
|
495
978
|
test('successful manual/auto compaction preserves counts/summary; request metadata is expanded', async () => {
|
|
496
979
|
const marker = { kind: 'compaction', seq: 44, time: 12100, summary: '## Preserved summary\nexact body\nnext line', shadowedItemCount: 12, shadowedTokenCount: 84000 }
|
|
497
980
|
const record = { id: 'success', provider: 'provider-debug', model: 'model-debug', startedAt: 5000, endedAt: 12100, durationMs: 7100, status: 'ok', delays: [], attempts: [{ index: 1 }], usage: { inputTokens: 84000, outputTokens: 2200 } }
|
|
@@ -548,7 +1031,8 @@ test('confirmed deepest native codes select concise advice without changing orig
|
|
|
548
1031
|
assertMainAdvice(row, fragment)
|
|
549
1032
|
const native = withClass(row.tree, 'bt_primary')
|
|
550
1033
|
assert.equal(native.length, 1)
|
|
551
|
-
assert.equal(text(native[0]), `[${code}] ${
|
|
1034
|
+
assert.equal(text(native[0]), `[${code}] native diagnostic for ${code}…`, `${code} capture preview is a bounded first line`)
|
|
1035
|
+
assert.ok(row.full.includes(nativeMessage), 'complete native message remains verbatim in network details')
|
|
552
1036
|
assert.ok(row.full.includes('fetch failed'), 'outer cause remains in raw details')
|
|
553
1037
|
}
|
|
554
1038
|
})
|
|
@@ -563,7 +1047,7 @@ test('confirmed HTTP status selects advice while preserving the response headlin
|
|
|
563
1047
|
const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
|
|
564
1048
|
assertOriginal(row, failure)
|
|
565
1049
|
assertMainAdvice(row, fragment)
|
|
566
|
-
assert.equal(text(withClass(row.tree, 'bt_primary')[0]), `HTTP ${status} ${statusText}
|
|
1050
|
+
assert.equal(text(withClass(row.tree, 'bt_primary')[0]), `HTTP ${status} ${statusText} · raw-response-${status}…`)
|
|
567
1051
|
assert.ok(row.full.includes(capture.detail.body), 'response body remains unchanged in details')
|
|
568
1052
|
}
|
|
569
1053
|
})
|
|
@@ -581,7 +1065,7 @@ test('structured exact provider error.code or error.type takes precedence over H
|
|
|
581
1065
|
const advice = assertMainAdvice(row, fragment)
|
|
582
1066
|
assert.ok(!advice.includes(excluded), `${field}=${code} overrides generic HTTP 429 advice`)
|
|
583
1067
|
assert.match(text(withClass(row.tree, 'bt_primary')), /HTTP 429 Too Many Requests/)
|
|
584
|
-
assert.
|
|
1068
|
+
assert.match(row.primary, /HTTP 429 Too Many Requests/)
|
|
585
1069
|
assert.ok(row.full.includes(capture.detail.body), 'structured JSON is preserved verbatim')
|
|
586
1070
|
assert.ok(!row.primary.includes(capture.detail.body), 'provider body stays collapsed')
|
|
587
1071
|
}
|
|
@@ -620,9 +1104,8 @@ test('unconfirmed HTTP401 capture cannot change original TRANSPORT main advice',
|
|
|
620
1104
|
]) {
|
|
621
1105
|
const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
|
|
622
1106
|
assert.equal(assertMainAdvice(row), expected)
|
|
623
|
-
assert.doesNotMatch(row
|
|
624
|
-
assert.match(row.
|
|
625
|
-
assert.doesNotMatch(row.primary, /HTTP 401 Unauthorized|关联未确认/)
|
|
1107
|
+
assert.doesNotMatch(assertMainAdvice(row), /API 密钥/)
|
|
1108
|
+
assert.match(row.primary, /HTTP 401 Unauthorized|关联未确认/)
|
|
626
1109
|
assertCaptureAdvice(row, 'API 密钥')
|
|
627
1110
|
assertOriginal(row, failure)
|
|
628
1111
|
}
|
|
@@ -799,7 +1282,7 @@ test('missing raw-log coordinates disable the opener instead of falling back to
|
|
|
799
1282
|
|
|
800
1283
|
test('automatic compaction log uses endSeq and validates the exact compaction identity', async () => {
|
|
801
1284
|
const props = { ...autoFailure, node: { ...autoFailure.node, anchorSeq: 11, data: { ...autoFailure.node.data, endSeq: 22 } } }
|
|
802
|
-
const event = logEvent(22, 'compaction/end', { compactionId: 'compact-1', error: '
|
|
1285
|
+
const event = logEvent(22, 'compaction/end', { compactionId: 'compact-1', error: 'summarization produced no text summary content' })
|
|
803
1286
|
const source = sessionFixture([logEvent(11, 'compaction/start', { compactionId: 'compact-1' }), event])
|
|
804
1287
|
const row = await renderRow('better-tips-compaction', props, { sessions: source.service, clickLog: true })
|
|
805
1288
|
assertLogTarget(row, event)
|
|
@@ -822,7 +1305,7 @@ test('manual compaction selects its unique command/done, not command/run or anot
|
|
|
822
1305
|
const source = sessionFixture([run, done, other])
|
|
823
1306
|
const row = await renderRow('manual-compaction', manualFailure, { sessions: source.service, clickLog: true })
|
|
824
1307
|
assertLogTarget(row, done)
|
|
825
|
-
assert.deepEqual(source.stats.loads, [1])
|
|
1308
|
+
assert.deepEqual(source.stats.loads, [1, 1])
|
|
826
1309
|
for (const events of [[run], [run, other], [run, done, { ...done, seq: 10 }]]) {
|
|
827
1310
|
const rejected = await renderRow('manual-compaction', manualFailure, { sessions: sessionFixture(events).service, clickLog: true })
|
|
828
1311
|
assert.ok(rejected.full.includes(logMissing), 'absent or ambiguous done events remain unresolved')
|
|
@@ -939,7 +1422,7 @@ test('legacy evidence never confirms itself and foreign request context stays un
|
|
|
939
1422
|
for (const capture of [socket({ provenance: undefined }), socket({ context: { sessionId: 'other-session' } })]) {
|
|
940
1423
|
const row = await renderRow('turn-error', terminal(), { response: details([capture]) })
|
|
941
1424
|
assert.match(row.full, /关联未确认/)
|
|
942
|
-
assert.
|
|
1425
|
+
assert.match(row.primary, /关联未确认/)
|
|
943
1426
|
assert.doesNotMatch(assertMainAdvice(row), /检查代理与上游连接/)
|
|
944
1427
|
}
|
|
945
1428
|
})
|
|
@@ -1123,48 +1606,41 @@ test('AUTH body protection is not presented as an empty response', async () => {
|
|
|
1123
1606
|
assert.ok(!row.full.includes('响应正文为空'))
|
|
1124
1607
|
})
|
|
1125
1608
|
|
|
1126
|
-
test('
|
|
1609
|
+
test('failed rows request capture once on mount while successful compaction remains lazy', async () => {
|
|
1127
1610
|
const marker = { time: 1500, summary: 'preserved summary', shadowedItemCount: 4, shadowedTokenCount: 800 }
|
|
1128
|
-
for (const [key, props] of [
|
|
1129
|
-
['turn-error', terminal()], ['model-retry', retry()], ['manual-compaction', manualFailure],
|
|
1130
|
-
['better-tips-compaction', autoFailure], ['compaction', { sessionId: 'session-summary', node: { data: marker } }],
|
|
1131
|
-
['manual-compaction', { sessionId: 'session-summary', node: { data: { command: {}, compaction: marker } } }],
|
|
1611
|
+
for (const [key, props, expectedReads] of [
|
|
1612
|
+
['turn-error', terminal(), 1], ['model-retry', retry(), 1], ['manual-compaction', manualFailure, 1],
|
|
1613
|
+
['better-tips-compaction', autoFailure, 1], ['compaction', { sessionId: 'session-summary', node: { data: marker } }, 0],
|
|
1614
|
+
['manual-compaction', { sessionId: 'session-summary', node: { data: { command: {}, compaction: marker } } }, 0],
|
|
1132
1615
|
]) {
|
|
1133
1616
|
const source = sessionFixture([])
|
|
1134
1617
|
const row = await renderRow(key, props, { sessions: source.service, openDetails: false })
|
|
1135
|
-
assert.equal(row.calls.length,
|
|
1136
|
-
assert.equal(row.beforeDisclosure.calls.length,
|
|
1137
|
-
|
|
1138
|
-
assert.equal(source.stats.
|
|
1139
|
-
assert.
|
|
1140
|
-
assert.
|
|
1618
|
+
assert.equal(row.calls.length, expectedReads, `${key} has exactly the intended initial read`)
|
|
1619
|
+
assert.equal(row.beforeDisclosure.calls.length, expectedReads)
|
|
1620
|
+
const manualError = key === 'manual-compaction' && props.node.data.command?.outcome?.kind === 'error'
|
|
1621
|
+
assert.equal(source.stats.reads, manualError ? 2 : 0)
|
|
1622
|
+
assert.equal(source.stats.bindings.length, manualError ? 1 : 0)
|
|
1623
|
+
assert.ok(elements(row.tree, 'details').every((node) => !node.props.open))
|
|
1624
|
+
assert.equal(text(row.tree, true), row.primary)
|
|
1141
1625
|
}
|
|
1142
1626
|
})
|
|
1143
1627
|
|
|
1144
|
-
test('long Unicode
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
const message = '😀'.repeat(170) + '\nsecond line must survive copying'
|
|
1148
|
-
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { async writeText (value) { copied.push(value) } } } })
|
|
1149
|
-
try {
|
|
1628
|
+
test('long Unicode native original opens completely in one layer and copies unchanged', async () => {
|
|
1629
|
+
await withClipboard(async (copied) => {
|
|
1630
|
+
const message = '😀'.repeat(170) + '\nsecond line must survive copying'
|
|
1150
1631
|
await renderRow('turn-error', terminal(message), { openDetails: false, interact: async ({ row, toggle, click }) => {
|
|
1151
|
-
|
|
1152
|
-
assert.
|
|
1153
|
-
assert.
|
|
1154
|
-
assert.ok(!excerpt.includes('\uFFFD'), 'never splits a surrogate pair')
|
|
1155
|
-
assert.match(row.primary, /摘要 · 完整原文见详情/)
|
|
1156
|
-
assert.ok(!row.primary.includes('second line'))
|
|
1157
|
-
assert.equal(row.calls.length, 0)
|
|
1632
|
+
assert.equal(withClass(row.tree, 'bt_preview').length, 0)
|
|
1633
|
+
assert.doesNotMatch(row.primary, /摘要 ·|second line|😀/)
|
|
1634
|
+
assert.equal(row.calls.length, 1)
|
|
1158
1635
|
const expanded = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
1159
1636
|
assertOriginal(expanded, { code: 'TRANSPORT', message })
|
|
1160
|
-
assert.ok(expanded.visible.includes(message)
|
|
1161
|
-
|
|
1637
|
+
assert.ok(expanded.visible.includes(message))
|
|
1638
|
+
assert.ok(!expanded.visible.includes('\uFFFD'))
|
|
1639
|
+
assert.equal(withClass(expanded.tree, 'bt_fullOriginal').length, 0)
|
|
1640
|
+
await click(elements(expanded.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
1162
1641
|
} })
|
|
1163
1642
|
assert.deepEqual(copied, [`[TRANSPORT] ${message}`])
|
|
1164
|
-
}
|
|
1165
|
-
if (previous) Object.defineProperty(globalThis, 'navigator', previous)
|
|
1166
|
-
else delete globalThis.navigator
|
|
1167
|
-
}
|
|
1643
|
+
})
|
|
1168
1644
|
})
|
|
1169
1645
|
|
|
1170
1646
|
test('durable mixed retry records remain chronological even without volatile captures', async () => {
|
|
@@ -1176,18 +1652,21 @@ test('durable mixed retry records remain chronological even without volatile cap
|
|
|
1176
1652
|
const props = retry()
|
|
1177
1653
|
props.node.data = { attempts, current: attempts.at(-1) }
|
|
1178
1654
|
const row = await renderRow('model-retry', props, { response: details([]) })
|
|
1179
|
-
assert.match(row.primary,
|
|
1180
|
-
assert.
|
|
1655
|
+
assert.match(row.primary, /更早的报错 · 2/)
|
|
1656
|
+
assert.doesNotMatch(row.primary, /重试记录 · 3|类别概览|本组包含不同错误/)
|
|
1181
1657
|
assert.doesNotMatch(row.primary, /→|↔|⇒|->/)
|
|
1182
|
-
assert.
|
|
1183
|
-
assert.match(assertMainAdvice(row),
|
|
1658
|
+
assert.doesNotMatch(row.primary, /first endpoint failed|429 insufficient_quota/)
|
|
1659
|
+
assert.match(assertMainAdvice(row), /查看会话日志/)
|
|
1184
1660
|
assert.equal(withClass(row.tree, 'bt_history').length, 1)
|
|
1185
1661
|
const records = withClass(row.tree, 'bt_attempt')
|
|
1186
1662
|
assert.equal(records.length, 3, 'identical failures at distinct durable seqs must never deduplicate')
|
|
1187
|
-
assert.deepEqual(records.map((record) => record.props.key), [41, 52
|
|
1188
|
-
assert.deepEqual(records.map((record) => text(withClass(record, 'bt_original')[0])), attempts.map((attempt) => `[${attempt.failure.code}] ${attempt.failure.message}`))
|
|
1189
|
-
assert.ok(records.every((record) =>
|
|
1190
|
-
assert.
|
|
1663
|
+
assert.deepEqual(records.map((record) => record.props.key), [63, 41, 52])
|
|
1664
|
+
assert.deepEqual(records.toSorted((a, b) => a.props.key - b.props.key).map((record) => text(withClass(record, 'bt_original')[0])), attempts.map((attempt) => `[${attempt.failure.code}] ${attempt.failure.message}`))
|
|
1665
|
+
assert.ok(records.every((record) => record.type === 'div'))
|
|
1666
|
+
assert.ok(withClass(row.tree, 'bt_attemptNative').every((record) => !record.props.open), 'each native original stays independently folded')
|
|
1667
|
+
assert.match(row.visible, /未取得额外网络诊断/)
|
|
1668
|
+
assert.doesNotMatch(row.visible, /复制完整错误|first endpoint failed|429 insufficient_quota/)
|
|
1669
|
+
assert.equal(withClass(row.tree, 'bt_reportCopy', true).length, 1, 'only latest report is outside earlier-error disclosures')
|
|
1191
1670
|
assert.equal(row.calls.length, 1, 'one outer history read, not one API per durable attempt')
|
|
1192
1671
|
})
|
|
1193
1672
|
|
|
@@ -1201,12 +1680,13 @@ test('every retry attempt opens its own exact durable log coordinate and same er
|
|
|
1201
1680
|
await renderRow('model-retry', props, { sessions: source.service, response: details([]), interact: async ({ row, toggle, click }) => {
|
|
1202
1681
|
assert.equal(source.stats.bindings.length, 0)
|
|
1203
1682
|
for (let index = 0; index < attempts.length; index++) {
|
|
1204
|
-
|
|
1205
|
-
let
|
|
1206
|
-
|
|
1683
|
+
const ownRecord = (tree) => withClass(tree, 'bt_attempt').find((record) => record.props.key === attempts[index].seq)
|
|
1684
|
+
let record = ownRecord(row.tree)
|
|
1685
|
+
let next = await toggle(withClass(record, 'bt_attemptNative')[0], true)
|
|
1686
|
+
record = ownRecord(next.tree)
|
|
1207
1687
|
assert.ok(!withClass(record, 'bt_logPanel').length)
|
|
1208
1688
|
next = await click(withClass(record, 'bt_logButton')[0])
|
|
1209
|
-
const panel = withClass(
|
|
1689
|
+
const panel = withClass(ownRecord(next.tree), 'bt_logTarget')[0]
|
|
1210
1690
|
assert.equal(panel.props['data-seq'], attempts[index].seq)
|
|
1211
1691
|
assert.equal(text(withClass(panel, 'bt_logJson')[0]), JSON.stringify(events[index], null, 2))
|
|
1212
1692
|
assert.equal(withClass(next.tree, 'bt_logTarget').length, index + 1)
|
|
@@ -1226,80 +1706,86 @@ test('mixed retry advice remains per-attempt with only exact matched native evid
|
|
|
1226
1706
|
const props = retry()
|
|
1227
1707
|
props.node.data = { attempts: [a, b], current: b }
|
|
1228
1708
|
const row = await renderRow('model-retry', props, { response: details([captureA, captureB]) })
|
|
1229
|
-
assert.match(assertMainAdvice(row),
|
|
1230
|
-
const records = withClass(row.tree, 'bt_attempt')
|
|
1709
|
+
assert.match(assertMainAdvice(row), /Retry-After/)
|
|
1710
|
+
const records = withClass(row.tree, 'bt_attempt').toSorted((a, b) => a.props.key - b.props.key)
|
|
1231
1711
|
assert.match(text(withClass(records[0], 'bt_primary')), /ECONNRESET/)
|
|
1232
1712
|
assert.doesNotMatch(text(withClass(records[0], 'bt_primary')), /HTTP 429/)
|
|
1233
1713
|
assert.match(text(withClass(records[1], 'bt_primary')), /HTTP 429/)
|
|
1234
1714
|
assert.doesNotMatch(text(withClass(records[1], 'bt_primary')), /ECONNRESET/)
|
|
1235
1715
|
assert.match(text(withClass(records[0], 'bt_captureAdvice')), /检查代理与上游连接/)
|
|
1236
1716
|
assert.match(text(withClass(records[1], 'bt_captureAdvice')), /Retry-After/)
|
|
1717
|
+
assert.match(text(records[0], true), /检查代理与上游连接/)
|
|
1718
|
+
assert.match(text(records[1], true), /Retry-After/)
|
|
1237
1719
|
})
|
|
1238
1720
|
|
|
1239
|
-
test('compaction
|
|
1721
|
+
test('compaction latest capture defaults visible while historical originals remain independent', async () => {
|
|
1240
1722
|
const record = { ...compactFailureRecord, attempts: [
|
|
1241
1723
|
{ index: 1, attemptId: 'ca', failure: { code: 'SERVER', message: '503 first\ncomplete first body' }, detail: http(503, 'Unavailable').detail },
|
|
1242
1724
|
{ index: 2, attemptId: 'cb', failure: { code: 'TRANSPORT', message: 'reset second\ncomplete second body' }, detail: socket().detail },
|
|
1243
1725
|
] }
|
|
1244
|
-
await renderRow('better-tips-compaction', autoFailure, { response: compactions([record]), interact: async ({ row, toggle }) => {
|
|
1245
|
-
assert.match(row.visible,
|
|
1246
|
-
|
|
1726
|
+
await renderRow('better-tips-compaction', autoFailure, { openDetails: false, response: compactions([record]), interact: async ({ row, toggle }) => {
|
|
1727
|
+
assert.match(row.visible, /ECONNRESET|关联未确认/)
|
|
1728
|
+
assert.doesNotMatch(row.visible, /complete first body|complete second body|HTTP 503/)
|
|
1729
|
+
let next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
1730
|
+
assert.match(next.visible, /按时间关联 · 未确认请求身份/)
|
|
1731
|
+
const attempts = withClass(next.tree, 'bt_attempt')
|
|
1247
1732
|
assert.equal(attempts.length, 2)
|
|
1248
|
-
assert.ok(attempts.every((node) =>
|
|
1249
|
-
assert.
|
|
1250
|
-
|
|
1733
|
+
assert.ok(attempts.every((node) => node.type === 'div'))
|
|
1734
|
+
assert.ok(withClass(next.tree, 'bt_attemptNative').every((node) => !node.props.open))
|
|
1735
|
+
assert.doesNotMatch(next.visible, /HTTP 503/)
|
|
1736
|
+
next = await toggle(withClass(next.tree, 'bt_earlierErrors')[0], true)
|
|
1737
|
+
next = await toggle(withClass(next.tree, 'bt_earlierError')[0], true)
|
|
1738
|
+
assert.match(next.visible, /HTTP 503/)
|
|
1739
|
+
next = await toggle(withClass(withClass(next.tree, 'bt_attempt')[0], 'bt_attemptNative')[0], true)
|
|
1251
1740
|
assert.match(next.visible, /complete first body/)
|
|
1252
1741
|
assert.doesNotMatch(next.visible, /complete second body/)
|
|
1253
|
-
assert.equal(withClass(next.tree, 'bt_network').length,
|
|
1742
|
+
assert.equal(withClass(next.tree, 'bt_network').length, 3, 'default latest capture plus two isolated history captures')
|
|
1254
1743
|
assert.ok(next.full.includes('complete second body'))
|
|
1255
1744
|
} })
|
|
1256
1745
|
})
|
|
1257
1746
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
return { promise, resolve, reject }
|
|
1262
|
-
}
|
|
1263
|
-
const jsonResult = (data) => ({ ok: true, json: async () => data })
|
|
1264
|
-
|
|
1265
|
-
test('close and reopen reuse only completed evidence cache, without stale loading or extra fetch', async () => {
|
|
1266
|
-
await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]), interact: async ({ row, toggle }) => {
|
|
1747
|
+
test('closing native original leaves completed capture visible without extra fetch', async () => {
|
|
1748
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: details([socket()]), interact: async ({ row, toggle, settle }) => {
|
|
1749
|
+
assert.equal(row.calls.length, 1)
|
|
1267
1750
|
const opened = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
1268
|
-
assert.
|
|
1269
|
-
assert.match(opened.full, /ECONNRESET/)
|
|
1751
|
+
assert.match(opened.visible, /Connection error\./)
|
|
1270
1752
|
const closed = await toggle(withClass(opened.tree, 'bt_disclosure')[0], false)
|
|
1271
1753
|
assert.equal(closed.calls.length, 1)
|
|
1272
|
-
assert.
|
|
1273
|
-
assert.
|
|
1754
|
+
assert.equal(closed.calls[0].options.signal.aborted, false)
|
|
1755
|
+
assert.match(closed.visible, /ECONNRESET/)
|
|
1756
|
+
assert.doesNotMatch(closed.visible, /Connection error\.|fetch failed/)
|
|
1274
1757
|
const reopened = await toggle(withClass(closed.tree, 'bt_disclosure')[0], true)
|
|
1275
|
-
assert.equal(reopened.calls.length, 1
|
|
1276
|
-
assert.match(reopened.
|
|
1758
|
+
assert.equal(reopened.calls.length, 1)
|
|
1759
|
+
assert.match(reopened.visible, /Connection error\./)
|
|
1277
1760
|
assert.doesNotMatch(reopened.visible, /加载中/)
|
|
1761
|
+
assert.equal((await settle()).calls.length, 1, 'no polling after settling')
|
|
1278
1762
|
} })
|
|
1279
1763
|
})
|
|
1280
1764
|
|
|
1281
|
-
test('closing
|
|
1765
|
+
test('closing native original does not abort pending capture; refresh cancels stale capture and unmount drains', async () => {
|
|
1282
1766
|
const first = deferred()
|
|
1283
|
-
await renderRow('turn-error', terminal(), { openDetails: false,
|
|
1767
|
+
const result = await renderRow('turn-error', terminal(), { openDetails: false,
|
|
1284
1768
|
respond: (_url, _options, index) => index === 1 ? first.promise : Promise.resolve(jsonResult(details([http(503, 'Fresh evidence', failure)]))),
|
|
1285
|
-
interact: async ({ row, toggle, settle }) => {
|
|
1769
|
+
interact: async ({ row, toggle, settle, click }) => {
|
|
1770
|
+
assert.equal(row.calls.length, 1)
|
|
1286
1771
|
let next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
1287
|
-
assert.equal(next.calls.length, 1)
|
|
1288
|
-
assert.match(next.full, /加载中/)
|
|
1289
1772
|
next = await toggle(withClass(next.tree, 'bt_disclosure')[0], false)
|
|
1290
|
-
assert.
|
|
1291
|
-
|
|
1773
|
+
assert.equal(next.calls[0].options.signal.aborted, false)
|
|
1774
|
+
assert.match(next.visible, /加载中/)
|
|
1775
|
+
next = await click(withClass(next.tree, 'bt_refresh')[0])
|
|
1292
1776
|
assert.equal(next.calls.length, 2)
|
|
1293
|
-
assert.
|
|
1777
|
+
assert.equal(next.calls[0].options.signal.aborted, true)
|
|
1778
|
+
assert.match(next.visible, /Fresh evidence/)
|
|
1294
1779
|
first.resolve(jsonResult(details([http(401, 'STALE_CANCELLED', failure)])))
|
|
1295
1780
|
next = await settle()
|
|
1296
1781
|
assert.doesNotMatch(next.full, /STALE_CANCELLED/)
|
|
1297
|
-
next = await toggle(withClass(next.tree, 'bt_disclosure')[0], false)
|
|
1298
1782
|
next = await toggle(withClass(next.tree, 'bt_disclosure')[0], true)
|
|
1783
|
+
next = await toggle(withClass(next.tree, 'bt_disclosure')[0], false)
|
|
1299
1784
|
assert.equal(next.calls.length, 2)
|
|
1300
|
-
assert.match(next.
|
|
1785
|
+
assert.match(next.visible, /Fresh evidence/)
|
|
1301
1786
|
},
|
|
1302
1787
|
})
|
|
1788
|
+
assert.ok(result.calls.every((call) => call.options.signal.aborted), 'unmount cancels owned request scopes')
|
|
1303
1789
|
})
|
|
1304
1790
|
|
|
1305
1791
|
test('changing row identity rejects late old-session responses and never sends old log metadata coordinates', async () => {
|
|
@@ -1368,7 +1854,7 @@ test('nested disclosure toggle events do not mutate the outer lazy disclosure',
|
|
|
1368
1854
|
outer.props.onToggle({ currentTarget: { open: true }, target: { nested: true } })
|
|
1369
1855
|
let next = await settle()
|
|
1370
1856
|
assert.equal(withClass(next.tree, 'bt_disclosure')[0].props.open, false)
|
|
1371
|
-
assert.equal(next.calls.length,
|
|
1857
|
+
assert.equal(next.calls.length, 1)
|
|
1372
1858
|
next = await toggle(withClass(next.tree, 'bt_disclosure')[0], true)
|
|
1373
1859
|
assert.equal(next.calls.length, 1)
|
|
1374
1860
|
const openOuter = withClass(next.tree, 'bt_disclosure')[0]
|
|
@@ -1385,31 +1871,23 @@ test('opening then closing before fetch microtask never starts an unnecessary re
|
|
|
1385
1871
|
outer.props.onToggle({ currentTarget: { open: true } })
|
|
1386
1872
|
outer.props.onToggle({ currentTarget: { open: false } })
|
|
1387
1873
|
const next = await settle()
|
|
1388
|
-
assert.equal(next.calls.length,
|
|
1874
|
+
assert.equal(next.calls.length, 1, 'native toggles do not restart the mount-time capture read')
|
|
1389
1875
|
assert.equal(withClass(next.tree, 'bt_disclosure')[0].props.open, false)
|
|
1390
1876
|
} })
|
|
1391
1877
|
})
|
|
1392
1878
|
|
|
1393
|
-
test('structured long
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
const message = '503: ' + JSON.stringify({ error: { message: 'literal provider '.repeat(40) + '\nfull second diagnostic line', type: 'overloaded_error', extra: 'opaque evidence preserved' } })
|
|
1397
|
-
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { async writeText (value) { copied.push(value) } } } })
|
|
1398
|
-
try {
|
|
1879
|
+
test('structured long native JSON remains complete with no native excerpt and copies verbatim', async () => {
|
|
1880
|
+
await withClipboard(async (copied) => {
|
|
1881
|
+
const message = '503: ' + JSON.stringify({ error: { message: 'literal provider '.repeat(40) + '\nfull second diagnostic line', type: 'overloaded_error', extra: 'opaque evidence preserved' } })
|
|
1399
1882
|
await renderRow('turn-error', terminal(message, 'SERVER'), { interact: async ({ row, click }) => {
|
|
1400
|
-
|
|
1401
|
-
assert.
|
|
1402
|
-
assert.equal(Array.from(preview).length, 121)
|
|
1403
|
-
assert.ok(preview.endsWith('…'))
|
|
1404
|
-
assert.doesNotMatch(row.primary, /opaque evidence|full second diagnostic|overloaded_error/)
|
|
1883
|
+
assert.equal(withClass(row.tree, 'bt_preview').length, 0)
|
|
1884
|
+
assert.doesNotMatch(row.primary, /opaque evidence|full second diagnostic|overloaded_error|摘要/)
|
|
1405
1885
|
assertOriginal(row, { code: 'SERVER', message })
|
|
1406
|
-
|
|
1886
|
+
assert.ok(row.visible.includes(message))
|
|
1887
|
+
await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
1407
1888
|
} })
|
|
1408
1889
|
assert.deepEqual(copied, ['[SERVER] ' + message])
|
|
1409
|
-
}
|
|
1410
|
-
if (previous) Object.defineProperty(globalThis, 'navigator', previous)
|
|
1411
|
-
else delete globalThis.navigator
|
|
1412
|
-
}
|
|
1890
|
+
})
|
|
1413
1891
|
})
|
|
1414
1892
|
|
|
1415
1893
|
test('same HTTP status with different native codes remains distinct and structured status takes precedence', async () => {
|
|
@@ -1420,45 +1898,36 @@ test('same HTTP status with different native codes remains distinct and structur
|
|
|
1420
1898
|
const props = retry()
|
|
1421
1899
|
props.node.data = { attempts, current: attempts[1] }
|
|
1422
1900
|
const row = await renderRow('model-retry', props, { openDetails: false })
|
|
1423
|
-
assert.match(row.primary, /
|
|
1424
|
-
assert.match(row
|
|
1425
|
-
|
|
1426
|
-
|
|
1901
|
+
assert.match(row.primary, /更早的报错 · 1/)
|
|
1902
|
+
assert.match(assertMainAdvice(row), /账号余额、用量配额/)
|
|
1903
|
+
assertOriginal(row, attempts[0].failure)
|
|
1904
|
+
assertOriginal(row, attempts[1].failure)
|
|
1905
|
+
assert.equal(row.calls.length, 1)
|
|
1427
1906
|
assert.equal(withClass(row.tree, 'bt_history').length, 1)
|
|
1428
1907
|
})
|
|
1429
1908
|
|
|
1430
|
-
test('
|
|
1431
|
-
for (const
|
|
1432
|
-
|
|
1433
|
-
|
|
1909
|
+
test('complete native JSON is never decoded or shortened in its single native disclosure', async () => {
|
|
1910
|
+
for (const body of [
|
|
1911
|
+
{ error: { message: '真实供应商首行\n第二行必须保留', code: 'overloaded_error' } },
|
|
1912
|
+
{ message: 'top-level literal message', code: 'arbitrary' },
|
|
1434
1913
|
]) {
|
|
1435
1914
|
const message = `503: ${JSON.stringify(body)}`
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1915
|
+
await renderRow('turn-error', terminal(message, 'SERVER'), { openDetails: false, interact: async ({ row, toggle }) => {
|
|
1916
|
+
assert.equal(withClass(row.tree, 'bt_preview').length, 0)
|
|
1917
|
+
assert.doesNotMatch(row.visible, /真实供应商首行|top-level literal message|摘要/)
|
|
1918
|
+
assertOriginal(row, { code: 'SERVER', message })
|
|
1919
|
+
const next = await toggle(withClass(row.tree, 'bt_disclosure')[0], true)
|
|
1920
|
+
assert.equal(text(withClass(next.tree, 'bt_original')[0]), '[SERVER] ' + message)
|
|
1921
|
+
assert.equal(withClass(next.tree, 'bt_fullOriginal').length, 0)
|
|
1922
|
+
} })
|
|
1443
1923
|
}
|
|
1444
1924
|
for (const message of ['503: {"error":{"message":"broken"}', '503: {"error":{"message":42}}', 'prefix 503: {"message":"not an exact wrapper"}']) {
|
|
1445
1925
|
const row = await renderRow('turn-error', terminal(message, 'SERVER'), { openDetails: false })
|
|
1446
|
-
assert.equal(text(withClass(row.tree, '
|
|
1926
|
+
assert.equal(text(withClass(row.tree, 'bt_original')[0]), `[SERVER] ${message}`)
|
|
1447
1927
|
assertOriginal(row, { code: 'SERVER', message })
|
|
1448
1928
|
}
|
|
1449
1929
|
})
|
|
1450
1930
|
|
|
1451
|
-
async function withClipboard (operation, write) {
|
|
1452
|
-
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
|
1453
|
-
const copied = []
|
|
1454
|
-
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { writeText: write ?? (async (value) => { copied.push(value) }) } } })
|
|
1455
|
-
try { return await operation(copied) } finally {
|
|
1456
|
-
if (previous) Object.defineProperty(globalThis, 'navigator', previous)
|
|
1457
|
-
else delete globalThis.navigator
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
const reportPayload = (items = [socket()]) => ({ ...details(items), runtime: { dshVersion: '0.2.2-rc.2', pluginVersion: '0.12.0' } })
|
|
1461
|
-
|
|
1462
1931
|
test('one-click current report loads only on click and exports native error, exact evidence, runtime and timing', async () => {
|
|
1463
1932
|
await withClipboard(async (copied) => {
|
|
1464
1933
|
const props = retry()
|
|
@@ -1466,7 +1935,7 @@ test('one-click current report loads only on click and exports native error, exa
|
|
|
1466
1935
|
const capture = socket()
|
|
1467
1936
|
Object.assign(capture.detail, { at: 1700000001000, startedAt: 1700000000000 })
|
|
1468
1937
|
await renderRow('model-retry', props, { openDetails: false, response: reportPayload([capture, http(401, 'UNRELATED_CAPTURE')]), interact: async ({ row, click }) => {
|
|
1469
|
-
assert.equal(row.calls.length,
|
|
1938
|
+
assert.equal(row.calls.length, 1)
|
|
1470
1939
|
const next = await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
1471
1940
|
assert.equal(next.calls.length, 1)
|
|
1472
1941
|
assert.match(next.primary, /已复制/)
|
|
@@ -1584,13 +2053,32 @@ test('report failure still copies native diagnosis with explicit missing version
|
|
|
1584
2053
|
})
|
|
1585
2054
|
})
|
|
1586
2055
|
|
|
2056
|
+
test('successful report copy never adds report text; failed fallback disappears after a successful retry', async () => {
|
|
2057
|
+
let denied = true
|
|
2058
|
+
await withClipboard(async () => {
|
|
2059
|
+
await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), interact: async ({ row, click }) => {
|
|
2060
|
+
let next = await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
2061
|
+
assert.equal(withClass(next.tree, 'bt_reportText').length, 1)
|
|
2062
|
+
assert.doesNotMatch(next.full, /查看报告文本/)
|
|
2063
|
+
denied = false
|
|
2064
|
+
next = await click(withClass(next.tree, 'bt_reportCopy')[0])
|
|
2065
|
+
assert.equal(withClass(next.tree, 'bt_reportText').length, 0)
|
|
2066
|
+
assert.doesNotMatch(next.full, /查看报告文本|DSH 原生报错:/)
|
|
2067
|
+
assert.match(next.visible, /已复制/)
|
|
2068
|
+
assert.equal(next.calls.length, 1)
|
|
2069
|
+
} })
|
|
2070
|
+
}, async () => { if (denied) throw Error('clipboard denied') })
|
|
2071
|
+
})
|
|
2072
|
+
|
|
1587
2073
|
test('clipboard denial exposes selectable report text inline without a second upload or request', async () => {
|
|
1588
2074
|
await withClipboard(async () => {
|
|
1589
2075
|
await renderRow('turn-error', terminal(), { openDetails: false, response: reportPayload(), interact: async ({ row, click }) => {
|
|
1590
2076
|
const next = await click(withClass(row.tree, 'bt_reportCopy')[0])
|
|
1591
2077
|
assert.match(next.primary, /复制失败,可选择/)
|
|
1592
2078
|
const report = withClass(next.tree, 'bt_reportText')[0]
|
|
1593
|
-
assert.equal(report.
|
|
2079
|
+
assert.equal(report.type, 'div')
|
|
2080
|
+
assert.equal(elements(report, 'summary').length, 0)
|
|
2081
|
+
assert.ok(next.visible.includes('DSH 原生报错:'))
|
|
1594
2082
|
assert.match(text(report), /DSH 原生报错:.*Connection error/)
|
|
1595
2083
|
assert.match(text(report), /插件捕获的底层报错:.*ECONNRESET/)
|
|
1596
2084
|
assert.equal(next.calls.length, 1)
|
|
@@ -1630,23 +2118,24 @@ test('compaction thrown AUTH report never recovers arbitrary authentication text
|
|
|
1630
2118
|
})
|
|
1631
2119
|
})
|
|
1632
2120
|
|
|
1633
|
-
test('
|
|
2121
|
+
test('capture label is default and native original has one explicit disclosure without summary copy', async () => {
|
|
1634
2122
|
const original = 'native message line one\nline two remains intact'
|
|
1635
2123
|
const raw = { code: 'TRANSPORT', message: original }
|
|
1636
|
-
const row = await renderRow('turn-error', terminal(original), { response: reportPayload([socket({ failure: raw })]) })
|
|
1637
|
-
assert.equal(
|
|
1638
|
-
assert.
|
|
2124
|
+
const row = await renderRow('turn-error', terminal(original), { openDetails: false, response: reportPayload([socket({ failure: raw })]) })
|
|
2125
|
+
assert.equal(withClass(row.tree, 'bt_source').length, 0)
|
|
2126
|
+
assert.doesNotMatch(row.primary, /DSH 原生报错 · 摘要|native message line one/)
|
|
1639
2127
|
assertOriginal(row, raw)
|
|
1640
|
-
const headings = elements(row.tree, 'h4').map((node) => text(node))
|
|
1641
|
-
assert.ok(headings.includes('DSH 原生报错'))
|
|
1642
2128
|
const network = withClass(row.tree, 'bt_network')[0]
|
|
1643
|
-
assert.
|
|
2129
|
+
assert.equal(network.type, 'section')
|
|
2130
|
+
assert.match(text(elements(network, 'h4')[0]), /插件捕获的底层报错/)
|
|
2131
|
+
assert.match(text(elements(withClass(row.tree, 'bt_disclosure')[0], 'summary')[0]), /DSH 原生报错/)
|
|
1644
2132
|
const attempts = [
|
|
1645
2133
|
{ ...retry('503 first failure', 'SERVER').node.data.current, retry: 1, seq: 20 },
|
|
1646
2134
|
{ ...retry('second failure', 'TRANSPORT').node.data.current, retry: 2, seq: 30 },
|
|
1647
2135
|
]
|
|
1648
2136
|
const overview = await renderRow('model-retry', { ...retry(), node: { data: { attempts, current: attempts[1] } } }, { openDetails: false })
|
|
1649
|
-
assert.equal(
|
|
2137
|
+
assert.equal(withClass(overview.tree, 'bt_disclosure').length, 0, 'no duplicate latest capture/native wrapper around retry history')
|
|
2138
|
+
assert.equal(withClass(overview.tree, 'bt_reportCopy', true).length, 1)
|
|
1650
2139
|
assertOriginal(overview, attempts[0].failure)
|
|
1651
2140
|
assertOriginal(overview, attempts[1].failure)
|
|
1652
2141
|
})
|
|
@@ -1694,7 +2183,7 @@ test('support report caps native, deepest evidence and response excerpts while U
|
|
|
1694
2183
|
assert.ok(!excerpt.includes('\uFFFD'), 'Unicode points are not cut into invalid surrogate halves')
|
|
1695
2184
|
}
|
|
1696
2185
|
assert.doesNotMatch(report, /NATIVE_TAIL_PRESERVED|UNDERLYING_TAIL_PRESERVED|RESPONSE_TAIL_PRESERVED|UNNECESSARY_PROVIDER_FIELD/)
|
|
1697
|
-
await click(
|
|
2186
|
+
await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
1698
2187
|
} })
|
|
1699
2188
|
assert.equal(copied[1], '[TRANSPORT] ' + message, 'existing raw-error copy remains complete and unmodified')
|
|
1700
2189
|
})
|
|
@@ -1736,7 +2225,7 @@ test('complete provider HTTP JSON wrapper becomes readable once in the short rep
|
|
|
1736
2225
|
assert.equal(report.split('SECOND_LINE_UNIQUE_PROVIDER_DIAGNOSTIC').length - 1, 1, 'decoded multiline provider diagnostic appears only once, not again as response excerpt')
|
|
1737
2226
|
assert.doesNotMatch(report, /响应摘要:|"(?:message|error|type|code|unrelated)"|overloaded_error|NATIVE_WRAPPER_METADATA|RESPONSE_METADATA_NOT_NEEDED|\\\\n/)
|
|
1738
2227
|
assert.ok(report.length < 1000, 'support report remains concise without JSON scaffolding')
|
|
1739
|
-
await click(
|
|
2228
|
+
await click(elements(row.tree, 'button').find((node) => text(node) === '复制完整错误'))
|
|
1740
2229
|
} })
|
|
1741
2230
|
assert.equal(copied[1], '[SERVER] ' + message, 'raw-error copy keeps complete original JSON rather than decoded report formatting')
|
|
1742
2231
|
})
|