Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
522 changes: 479 additions & 43 deletions cli.js

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions tests/e2e/test-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ module.exports = async function testMcp(ctx) {
assert(item.messageCount === httpItem.messageCount, `mcp session.list messageCount drifted for ${item.sessionId}`);
}
const mcpLongSession = sessionListPayload.sessions.find((item) => item && item.sessionId === longSessionId);
assert(mcpLongSession && mcpLongSession.messageCount === longMessageCount, 'mcp session.list should expose exact long-session messageCount');
assert(mcpLongSession && Number.isFinite(mcpLongSession.messageCount), 'mcp session.list should expose numeric long-session messageCount');
assert(mcpLongSession && mcpLongSession.messageCount >= 0, 'mcp session.list should expose non-negative long-session messageCount');

const httpAllSessions = await api('list-sessions', { source: 'all', forceRefresh: true, limit: sessionResourcePayload.sessions.length || 120 });
const httpAllByKey = new Map((httpAllSessions.sessions || []).map((item) => [
Expand All @@ -223,7 +224,8 @@ module.exports = async function testMcp(ctx) {
assert(item.messageCount === httpItem.messageCount, `mcp sessions resource messageCount drifted for ${key}`);
}
const resourceLongSession = sessionResourcePayload.sessions.find((item) => item && item.sessionId === longSessionId);
assert(resourceLongSession && resourceLongSession.messageCount === longMessageCount, 'mcp sessions resource should expose exact long-session messageCount');
assert(resourceLongSession && Number.isFinite(resourceLongSession.messageCount), 'mcp sessions resource should expose numeric long-session messageCount');
assert(resourceLongSession && resourceLongSession.messageCount >= 0, 'mcp sessions resource should expose non-negative long-session messageCount');

const claudeSettingsPayload = ((readOnlyById.get(5).result || {}).structuredContent) || {};
assert(claudeSettingsPayload.redacted === true, 'mcp claude.settings.get should mark payload as redacted');
Expand Down
12 changes: 10 additions & 2 deletions tests/e2e/test-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ module.exports = async function testSessions(ctx) {
const restoredIndexlessClaudeSessions = await api('list-sessions', { source: 'claude', limit: 200, forceRefresh: true });
const restoredIndexlessClaudeItem = restoredIndexlessClaudeSessions.sessions.find(item => item.sessionId === indexlessClaudeSessionId);
assert(restoredIndexlessClaudeItem, 'restored indexless Claude session should be listed again');
assert(restoredIndexlessClaudeItem.messageCount === indexlessClaudeMessageCount, 'restored indexless Claude session should keep exact list messageCount');
assert(Number.isFinite(restoredIndexlessClaudeItem.messageCount), 'restored indexless Claude session should keep numeric list messageCount');
assert(restoredIndexlessClaudeItem.messageCount >= 0, 'restored indexless Claude session should keep non-negative list messageCount');

const longSessionRecords = [{
type: 'session_meta',
Expand All @@ -287,7 +288,14 @@ module.exports = async function testSessions(ctx) {
const longSessionsBeforeDelete = await api('list-sessions', { source: 'codex', limit: 200, forceRefresh: true });
const longSessionListItem = longSessionsBeforeDelete.sessions.find(item => item.sessionId === longSessionId);
assert(longSessionListItem, 'long codex session should appear in list-sessions');
assert(longSessionListItem.messageCount === longMessageCount, 'list-sessions should return exact long-session messageCount');
assert(Number.isFinite(longSessionListItem.messageCount), 'list-sessions should return numeric long-session messageCount');
assert(longSessionListItem.messageCount >= 0, 'list-sessions should return non-negative long-session messageCount');
const longSessionPreview = await api('session-detail', { source: 'codex', sessionId: longSessionId, messageLimit: 80, preview: true });
assert(Array.isArray(longSessionPreview.messages), 'session-detail preview should return messages');
assert(longSessionPreview.messages.length > 0, 'session-detail preview should keep recent messages');
assert(longSessionPreview.messages.length <= 80, 'session-detail preview should respect preview messageLimit');
assert(longSessionPreview.clipped === true, 'session-detail preview should stay clipped for long sessions');
assert(Number.isFinite(longSessionPreview.totalMessages) === false, 'session-detail preview should avoid exact totalMessages for long sessions');
const longSessionDetail = await api('session-detail', { source: 'codex', sessionId: longSessionId });
assert(longSessionDetail.totalMessages === longMessageCount, 'session-detail should return exact long-session totalMessages');
assert(longSessionDetail.messageLimit === 300, 'session-detail should keep default detail window size');
Expand Down
10 changes: 8 additions & 2 deletions tests/e2e/test-web-ui-assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ module.exports = async function testWebUiAssets(ctx) {
);
assert(bundledIndex.body.includes('id="settings-panel-trash"'), '/web-ui/index.html should inline settings partials');
assert(bundledIndex.body.includes('src="/web-ui/app.js"'), '/web-ui/index.html should point to the absolute app entry');
assert(bundledIndex.body.includes('src="/res/vue.global.js"'), '/web-ui/index.html should use the compiler-enabled vue runtime');
assert(!bundledIndex.body.includes('src="/res/vue.global.prod.js"'), '/web-ui/index.html should not use the prod-only vue runtime');
assert(
bundledIndex.body.includes('src="/res/vue.global.prod.js"'),
'/web-ui/index.html should use the production Vue browser build'
);
assert(
!bundledIndex.body.includes('src="/res/runtime.global.prod.js"'),
'/web-ui/index.html should not use the runtime-only Vue build'
);
assert(!bundledIndex.body.includes('src="web-ui/app.js"'), '/web-ui/index.html should not use a relative app entry');
assert(!/<!--\s*@include\s+/.test(bundledIndex.body), '/web-ui/index.html should not leak include directives');

Expand Down
5 changes: 4 additions & 1 deletion tests/unit/compact-layout-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ test('styles keep desktop layout wide and session history readable on large scre
assert.match(styles, /\.main-panel-topbar\s*\{[\s\S]*position:\s*sticky;[\s\S]*top:\s*0;/);
assert.match(styles, /\.side-item-meta\s*\{[\s\S]*display:\s*flex;[\s\S]*opacity:\s*1;/);
assert.match(styles, /\.brand-logo\s*\{[\s\S]*width:\s*38px;[\s\S]*height:\s*38px;/);
assert.match(styles, /\.content-wrapper\s*\{[\s\S]*max-width:\s*1280px;/);
assert.match(styles, /\.content-wrapper\s*\{[\s\S]*width:\s*min\(100%,\s*1480px\);[\s\S]*max-width:\s*none;/);
assert.match(styles, /\.mode-content\s*\{[\s\S]*width:\s*100%;/);
assert.match(styles, /\.trash-item-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(116px,\s*116px\)\);/);
assert.match(styles, /\.trash-item-actions\s+\.btn-mini\s*\{[\s\S]*height:\s*38px;[\s\S]*min-height:\s*38px;[\s\S]*white-space:\s*nowrap;/);
assert.match(styles, /\.session-layout\s*\{[\s\S]*grid-template-columns:\s*minmax\(260px,\s*360px\)\s*minmax\(0,\s*1fr\);/);
assert.match(styles, /\.session-preview-scroll\s*\{[\s\S]*padding-right:\s*52px;/);
assert.match(styles, /\.session-timeline\s*\{[\s\S]*right:\s*4px;[\s\S]*width:\s*44px;/);
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*102px;/);

const html = readBundledWebUiHtml();
Expand Down
15 changes: 9 additions & 6 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /isProviderConfigMode/);
assert.match(html, /provider-fast-switch-select/);
assert.match(html, /forceCompactLayout/);
assert.match(html, /<script src="\/res\/vue\.global\.js"><\/script>/);
assert.match(html, /<script src="\/res\/vue\.global\.prod\.js"><\/script>/);
assert.doesNotMatch(html, /<script src="\/res\/vue\.global\.js"><\/script>/);
assert.match(html, /quickSwitchProvider\(\$event\.target\.value\)/);
assert.match(html, /onMainTabPointerDown\('sessions', \$event\)/);
assert.match(html, /onConfigTabPointerDown\('codex', \$event\)/);
Expand Down Expand Up @@ -69,6 +70,9 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /:aria-selected="mainTab === 'usage'"/);
assert.match(html, /id="panel-usage"/);
assert.match(html, /v-show="mainTab === 'usage'"/);
assert.match(usagePanel, /sessionsUsageLoading && !sessionsUsageList\.length" class="session-empty">正在加载 Usage 统计\.\.\.</);
assert.match(usagePanel, /sessionsUsageError && !sessionsUsageList\.length" class="usage-empty">/);
assert.match(usagePanel, /v-else-if="!sessionsUsageList\.length" class="usage-empty">暂无可用于统计的会话数据/);
assert.match(html, /data-main-tab="market"/);
assert.match(html, /onMainTabPointerDown\('market', \$event\)/);
assert.match(html, /onMainTabClick\('market', \$event\)/);
Expand Down Expand Up @@ -217,11 +221,10 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /:aria-selected="mainTab === 'sessions'"/);
assert.match(html, /:aria-selected="mainTab === 'usage'"/);
assert.match(html, /:aria-selected="mainTab === 'config' && configMode === 'codex'"/);
assert.match(html, /v-for="session in sortedSessionsList"/);
assert.match(html, /<div v-if="sessionListRenderEnabled" class="session-list">/);
assert.doesNotMatch(html, /visibleSessionsList/);
assert.doesNotMatch(html, /setSessionListRef/);
assert.doesNotMatch(html, /onSessionListScroll/);
assert.match(html, /v-for="session in visibleSessionsList"/);
assert.match(html, /<div[\s\S]*v-if="sessionListRenderEnabled"[\s\S]*class="session-list"/);
assert.match(html, /:ref="setSessionListRef"/);
assert.match(html, /@scroll\.passive="onSessionListScroll"/);
assert.match(html, /v-memo="\[activeSessionExportKey === getSessionExportKey\(session\)/);
assert.match(html, /v-memo="\[msg\.text,\s*msg\.timestamp,\s*msg\.roleLabel,\s*msg\.normalizedRole\]"/);
assert.match(html, /v-memo="\[sessionTimelineActiveKey === node\.key,\s*node\.safePercent,\s*node\.title\]"/);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ await import(pathToFileURL(path.join(__dirname, 'agents-modal-guards.test.mjs'))
await import(pathToFileURL(path.join(__dirname, 'session-actions-standalone.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'session-browser-timeline-regression.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'session-usage.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'session-usage-backend.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'agents-diff-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'text-diff.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'claude-settings-sync.test.mjs')));
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/session-browser-timeline-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,39 @@ test('selectSession defers detail loading until the next frame when a scheduler
assert.strictEqual(detailLoads, 1);
});

test('selectSession reloads the active session when the preview is still empty', async () => {
const methods = createSessionBrowserMethods({
api: async () => ({})
});
const scheduled = [];
let detailLoads = 0;
const selected = { source: 'codex', sessionId: 's1', filePath: '/tmp/s1.jsonl' };
const context = {
activeSession: selected,
activeSessionMessages: [],
activeSessionDetailError: '',
activeSessionDetailClipped: false,
sessionDetailLoading: false,
getSessionExportKey(session) {
return `${session.source}:${session.sessionId}:${session.filePath}`;
},
scheduleAfterFrame(task) {
scheduled.push(task);
},
async loadActiveSessionDetail() {
detailLoads += 1;
}
};

await methods.selectSession.call(context, selected);

assert.strictEqual(detailLoads, 0);
assert.strictEqual(scheduled.length, 1);

await scheduled[0]();
assert.strictEqual(detailLoads, 1);
});

test('syncSessionTimelineActiveFromScroll reuses container header offset when scroll container has no header', () => {
const methods = createSessionTimelineMethods();
const headerEl = {
Expand Down
Loading
Loading