Files
wehub-resource-sync f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
chore: import upstream snapshot with attribution
2026-07-13 12:31:48 +08:00

569 lines
20 KiB
Python

from __future__ import annotations
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is required for viewer JS unit tests")
def test_viewer_split_js_core_units_run_without_playwright() -> None:
script = textwrap.dedent(
r"""
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const repoRoot = process.argv.at(-1);
const assetDir = path.join(repoRoot, 'claude_tap', 'viewer_assets');
function classList() {
return { add() {}, remove() {}, toggle() {}, contains() { return false; } };
}
function element() {
return {
style: {},
dataset: {},
classList: classList(),
children: [],
innerHTML: '',
textContent: '',
value: '',
setAttribute() {},
appendChild(child) { this.children.push(child); return child; },
removeChild(child) { this.children = this.children.filter(item => item !== child); },
addEventListener() {},
querySelector() { return null; },
querySelectorAll() { return []; },
focus() {},
select() {},
setSelectionRange() {},
remove() {},
};
}
const context = {
console,
URLSearchParams,
setTimeout() {},
clearTimeout() {},
requestAnimationFrame(callback) { if (typeof callback === 'function') callback(); return 1; },
cancelAnimationFrame() {},
window: {
location: { search: '?embed=1&hideHeader=1&density=compact&theme=dark' },
localStorage: { getItem() { return null; }, setItem() {} },
matchMedia() { return { matches: false }; },
},
navigator: { language: 'en', clipboard: null },
document: {
documentElement: { dataset: {}, classList: classList() },
body: element(),
querySelector() { return element(); },
querySelectorAll() { return []; },
getElementById() { return element(); },
createElement() { return element(); },
addEventListener() {},
removeEventListener() {},
execCommand() { return false; },
},
};
vm.createContext(context);
for (const assetName of [
'state.js',
'responses.js',
'lazy_loading.js',
'i18n_ui.js',
'live_bootstrap.js',
'filters_search.js',
'renderers.js',
'diff.js',
'utilities_mobile.js',
]) {
const source = fs.readFileSync(path.join(assetDir, assetName), 'utf8');
vm.runInContext(source, context, { filename: assetName });
}
const plain = value => JSON.parse(JSON.stringify(value));
assert.deepEqual(plain(context.parseEmbedQueryOptions()), {
enabled: true,
hideHeader: true,
hidePath: false,
hideHistory: false,
hideControls: false,
compact: true,
theme: 'dark',
});
assert.deepEqual(plain(context.turnSortSegments('1.02.beta')), [1, 2, 0]);
assert.equal(context.compareTurns('1.10', '1.2') > 0, true);
assert.equal(context.compareTurns('2', '10') < 0, true);
assert.deepEqual(
plain(context.lineDiff('alpha\nold\nsame', 'alpha\nnew\nsame')),
[
{ type: 'ctx', text: 'alpha' },
{ type: 'change', oldText: 'old', newText: 'new' },
{ type: 'ctx', text: 'same' },
],
);
const events = [
{ event: 'response.created', data: { response: { id: 'resp_first' } } },
{
event: 'response.output_item.done',
data: {
output_index: 0,
item: {
id: 'item_first_tool',
type: 'function_call',
call_id: 'call_1',
name: 'shell',
arguments: '{"cmd":"pwd"}',
},
},
},
{
event: 'response.completed',
data: { response: { id: 'resp_first', output: [], usage: { output_tokens: 1 } } },
},
{ event: 'response.created', data: { response: { id: 'resp_prefetch', generate: false } } },
{
event: 'response.completed',
data: { response: { id: 'resp_prefetch', generate: false, usage: { output_tokens: 0 } } },
},
];
const groups = context.splitWebSocketResponseEvents(events);
assert.equal(groups.length, 2);
assert.equal(context.completedResponseFromEvents(groups[0].events).id, 'resp_first');
assert.deepEqual(
plain(groups.filter(group => context.isDisplayableWebSocketResponseGroup(group)).map(group => group.responseId)),
['resp_first'],
);
assert.deepEqual(plain(context.webSocketOutputMessages(groups[0].events)), [
{
type: 'message',
role: 'assistant',
content: [{
type: 'tool_use',
id: 'call_1',
name: 'shell',
input: { cmd: 'pwd' },
}],
},
]);
assert.deepEqual(plain(context.normalizeDisplayContentBlocks([
{ type: 'input_text', text: 'hello' },
{ type: 'input_image', source: { media_type: 'image/png', data: 'base64-data' } },
{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' },
])), [
{ type: 'input_text', text: 'hello' },
{ type: 'input_image', source: { media_type: 'image/png', data: 'base64-data' } },
{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' },
]);
assert.deepEqual(plain(context.getMessages({
instructions: 'Be concise',
input: [{ role: 'user', content: [{ type: 'input_text', text: 'Hi' }] }],
})), [
{ role: 'developer', content: [{ type: 'text', text: 'Be concise' }] },
{ role: 'user', content: [{ type: 'input_text', text: 'Hi' }] },
]);
assert.deepEqual(
plain(context.getRequestTools({
model: 'gpt-5.6-sol',
input: [{
type: 'additional_tools',
role: 'developer',
tools: [
{ name: 'exec', description: 'Run a command' },
{ name: 'wait' },
{ name: 'request_user_input' },
],
}],
}).map(tool => context.toolDisplayName(tool))),
['exec', 'wait', 'request_user_input'],
);
assert.deepEqual(
plain(context.getRequestTools({
tools: [{ name: 'exec' }],
input: [{
type: 'additional_tools',
tools: [{ name: 'exec' }, { name: 'collaboration' }],
}],
}).map(tool => context.toolDisplayName(tool))),
['exec', 'collaboration'],
);
const codexPrefetchId = 'resp_prefetch_tools';
const codexVisibleId = 'resp_visible';
const codexExpanded = context.expandWebSocketResponseEntries([
{
transport: 'websocket',
request: {
method: 'WEBSOCKET',
path: '/v1/responses',
body: {
model: 'gpt-5.6-sol',
generate: false,
input: [{
type: 'additional_tools',
role: 'developer',
tools: [
{ name: 'exec' },
{ name: 'wait' },
{ name: 'request_user_input' },
{ name: 'collaboration' },
],
}],
},
},
response: {
body: {
id: codexPrefetchId,
generate: false,
output: [],
usage: { input_tokens: 10, output_tokens: 0 },
},
},
},
{
transport: 'websocket',
request: {
method: 'WEBSOCKET',
path: '/v1/responses',
body: {
model: 'gpt-5.6-sol',
previous_response_id: codexPrefetchId,
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Run pwd' }] }],
},
},
response: {
body: {
id: codexVisibleId,
previous_response_id: codexPrefetchId,
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }],
usage: { input_tokens: 20, output_tokens: 2 },
},
},
},
]);
assert.equal(codexExpanded.length, 1);
assert.deepEqual(
plain(context.getRequestTools(codexExpanded[0].request.body).map(tool => context.toolDisplayName(tool))),
['exec', 'wait', 'request_user_input', 'collaboration'],
);
assert.deepEqual(
plain(context.getMessages(codexExpanded[0].request.body).map(message => message.role)),
['user'],
);
const compactBundle = {
__claude_tap_compact_trace__: { version: 1 },
blobs: {
hash_1: {
kind: 'json',
payload: {
method: 'POST',
path: '/v1/responses',
body: { input: [{ role: 'user', content: 'compact prompt' }] },
},
},
},
records: [{
__claude_tap_compact_record__: {
version: 1,
refs: [{ path: '/request', hash: 'hash_1', bytes: 100 }],
},
record: {
turn: 1,
request: {
__claude_tap_blob_ref__: { version: 1, kind: 'json', hash: 'hash_1' },
},
response: {
status: 200,
body: {
output: [{
type: 'message',
content: [{
type: 'output_text',
text: 'marker-shaped user payload',
metadata: {
__claude_tap_blob_ref__: {
version: 1,
kind: 'json',
hash: 'user-controlled-marker-shape',
},
},
}],
}],
},
},
},
}],
};
const fakeUserMarker = {
__claude_tap_blob_ref__: {
version: 1,
kind: 'json',
hash: 'user-controlled-marker-shape',
},
};
assert.deepEqual(plain(context.materializeCompactTraceBundle(compactBundle)), [{
turn: 1,
request: {
method: 'POST',
path: '/v1/responses',
body: { input: [{ role: 'user', content: 'compact prompt' }] },
},
response: {
status: 200,
body: {
output: [{
type: 'message',
content: [{
type: 'output_text',
text: 'marker-shaped user payload',
metadata: fakeUserMarker,
}],
}],
},
},
}]);
assert.deepEqual(
plain(context.parseTraceText(JSON.stringify(compactBundle))),
plain(context.materializeCompactTraceBundle(compactBundle)),
);
const legacyCompactBundle = {
__claude_tap_compact_trace__: { version: 1 },
blobs: {
hash_legacy_instructions: {
kind: 'json',
payload: 'legacy compact instructions',
},
hash_legacy_input: {
kind: 'json',
payload: {
role: 'user',
content: [{ type: 'input_text', text: 'legacy compact input item' }],
},
},
},
records: [{
__claude_tap_compact_record__: {
version: 1,
encoding: 'json-blob-ref',
},
record: {
turn: 2,
request: {
body: {
instructions: {
__claude_tap_blob_ref__: { version: 1, kind: 'json', hash: 'hash_legacy_instructions' },
},
input: [
{
__claude_tap_blob_ref__: { version: 1, kind: 'json', hash: 'hash_legacy_input' },
},
{
role: 'user',
content: [{ type: 'input_text', text: 'keep marker shape' }],
metadata: fakeUserMarker,
},
],
},
},
response: { body: { output: [] } },
},
}],
};
assert.deepEqual(plain(context.materializeCompactTraceBundle(legacyCompactBundle)), [{
turn: 2,
request: {
body: {
instructions: 'legacy compact instructions',
input: [
{
role: 'user',
content: [{ type: 'input_text', text: 'legacy compact input item' }],
},
{
role: 'user',
content: [{ type: 'input_text', text: 'keep marker shape' }],
metadata: fakeUserMarker,
},
],
},
},
response: { body: { output: [] } },
}]);
/* ── normalizeUsage: provider-aware cache flag ── */
// OpenAI-style: cached_tokens embedded in prompt_tokens via details
const openaiUsage = context.normalizeUsage({
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 60 },
});
assert.equal(openaiUsage.input_tokens, 100);
assert.equal(openaiUsage.cache_read_input_tokens, 60);
assert.equal(openaiUsage._cache_read_in_input, true);
// Claude/Anthropic-style: cache_read_input_tokens separate from input_tokens
const claudeUsage = context.normalizeUsage({
input_tokens: 40,
output_tokens: 20,
cache_read_input_tokens: 60,
cache_creation_input_tokens: 10,
});
assert.equal(claudeUsage.input_tokens, 40);
assert.equal(claudeUsage.cache_read_input_tokens, 60);
assert.equal(claudeUsage._cache_read_in_input, false);
// Bedrock Converse-style camelCase: cacheReadInputTokens is a separate bucket
const bedrockUsage = context.normalizeUsage({
inputTokens: 9,
outputTokens: 1,
cacheReadInputTokens: 12,
cacheWriteInputTokens: 2,
});
assert.equal(bedrockUsage.input_tokens, 9);
assert.equal(bedrockUsage.cache_read_input_tokens, 12);
assert.equal(bedrockUsage.cache_creation_input_tokens, 2);
assert.equal(bedrockUsage._cache_read_in_input, false);
// No cache data at all: flag should be absent
const noCacheUsage = context.normalizeUsage({ input_tokens: 100, output_tokens: 50 });
assert.equal(noCacheUsage.cache_read_input_tokens, undefined);
assert.equal(noCacheUsage._cache_read_in_input, undefined);
/* ── Cache hit rate denominator correctness ── */
// Simulate OpenAI-style: cache embedded in input → rate = 60/100 = 60%
// denominator = input_tokens = 100
const openaiRate = Math.round(60 / 100 * 100);
assert.equal(openaiRate, 60);
// Simulate Claude-style: cache separate → total input-side = 40+60+10 = 110
// rate = 60/110 = 55% (NOT 60/40 = 150% which is the old buggy result)
const claudeTotalInput = 40 + 60 + 10;
const claudeRate = Math.round(60 / claudeTotalInput * 100);
assert.equal(claudeRate, 55);
assert.ok(claudeRate <= 100, 'Claude-style rate must not exceed 100%');
/* ── Direct DOM test: #stat-cache-hit-rate via applyFilter() ── */
context.assert = assert;
context.element = element;
vm.runInContext(`
// Persistent stat elements so applyFilter can set textContent
const _statEls = {};
document.querySelector = function (sel) {
if (typeof sel === 'string' && sel.startsWith('#')) {
const id = sel.slice(1);
if (!_statEls[id]) _statEls[id] = element();
return _statEls[id];
}
return element();
};
// Stub heavy rendering helpers irrelevant to stat computation
renderSidebar = function () {};
updatePositionIndicator = function () {};
renderToolFilter = function () {};
renderPathFilter = function () {};
renderTracePathBar = function () {};
function makeUsageEntry(usage, path) {
return {
request: { path: path || '/v1/messages', method: 'POST', body: {} },
response: { body: { usage } },
turn: '1',
duration_ms: 100,
};
}
// Claude-style: cache_read separate from input → 60/(40+60+10)=55%
entries = [makeUsageEntry({
input_tokens: 40, output_tokens: 20,
cache_read_input_tokens: 60, cache_creation_input_tokens: 10,
})];
activePaths = new Set(['/v1/messages']);
searchQuery = '';
activeTools = null;
applyFilter();
assert.equal(_statEls['stat-cache-hit-rate'].textContent, '55%',
'Claude-style direct DOM: expected 55%');
assert.equal(_statEls['stat-cache-hit-rate-group'].style.display, 'flex',
'Claude-style direct DOM: group should be visible');
// OpenAI-style: cache embedded in input → 60/100=60%
entries = [makeUsageEntry({
prompt_tokens: 100, completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 60 },
})];
applyFilter();
assert.equal(_statEls['stat-cache-hit-rate'].textContent, '60%',
'OpenAI-style direct DOM: expected 60%');
// Bedrock camelCase: cache_read separate from input → 12/(9+12+2)=52%
entries = [makeUsageEntry({
inputTokens: 9, outputTokens: 1,
cacheReadInputTokens: 12, cacheWriteInputTokens: 2,
})];
applyFilter();
assert.equal(_statEls['stat-cache-hit-rate'].textContent, '52%',
'Bedrock camelCase direct DOM: expected 52%');
// No cache data: group should be hidden
entries = [makeUsageEntry({ input_tokens: 100, output_tokens: 50 })];
applyFilter();
assert.equal(_statEls['stat-cache-hit-rate-group'].style.display, 'none',
'No-cache direct DOM: group should be hidden');
// Mixed providers: OpenAI(100,cache=60) + Claude(40,cache_read=60,create=10)
// denom = 100 + 110 = 210, cache_read = 120, rate = 57%
entries = [
makeUsageEntry({
prompt_tokens: 100, completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 60 },
}),
makeUsageEntry({
input_tokens: 40, output_tokens: 20,
cache_read_input_tokens: 60, cache_creation_input_tokens: 10,
}),
];
applyFilter();
assert.equal(_statEls['stat-cache-hit-rate'].textContent, '57%',
'Mixed-provider direct DOM: expected 57%');
// Mixed cached and uncached entries: uncached input still belongs in denominator
// denom = OpenAI input 100 + uncached input 100, cache_read = 60, rate = 30%
entries = [
makeUsageEntry({
prompt_tokens: 100, completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 60 },
}),
makeUsageEntry({ input_tokens: 100, output_tokens: 10 }),
];
applyFilter();
assert.equal(_statEls['stat-cache-hit-rate'].textContent, '30%',
'Mixed cached/uncached direct DOM: expected 30%');
`, context);
"""
)
subprocess.run(["node", "-e", script, str(REPO_ROOT)], check=True, capture_output=True, text=True)