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
569 lines
20 KiB
Python
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)
|