From d0b925bcf3809719e33ab41fb2cf37884a18f507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=B4=D0=B8=D0=BC=20=D0=A0=D0=BE=D1=81=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Tue, 23 Jun 2026 14:35:45 +0200 Subject: [PATCH] fix(codeburn): restore menubar project token reporting --- CHANGELOG.md | 2 + .../codeburn-restore-verification.md | 65 +++++++++++ mac/Scripts/smoke-popover.sh | 30 +++++ mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 108 ++++++++++++++++++ .../CodeBurnMenubar/Data/MenubarPayload.swift | 19 ++- src/menubar-json.ts | 72 ++++++++++-- src/usage-aggregator.ts | 6 + tests/menubar-json.test.ts | 70 ++++++++++++ 8 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 docs/verification/codeburn-restore-verification.md create mode 100755 mac/Scripts/smoke-popover.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b34a481..2273297b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ ### Fixed (macOS menubar) - **Keychain prompts.** Stop repeated keychain prompts on token refresh; read the Claude keychain via the `security` CLI on silent refresh. (#490, #491) +- Menubar project rows are now grouped by project name with token totals preserved, + preventing duplicate project rows in the popover smoke path. - Restore the right-click status-item menu on macOS 27. (#472, thanks @theparlor) - Support installer HTTP proxies. (#475, thanks @sleicht) - Surface the CLI's stdout/stderr on a decode failure so a stray banner is diff --git a/docs/verification/codeburn-restore-verification.md b/docs/verification/codeburn-restore-verification.md new file mode 100644 index 00000000..fb6c0a50 --- /dev/null +++ b/docs/verification/codeburn-restore-verification.md @@ -0,0 +1,65 @@ +# CodeBurn Menubar Restore Verification + +Date: 2026-06-23 +Branch: `codex/please-implement-this-plan-codeburn-rebased` + +## Scope + +Restore and verify the existing macOS menubar path without adding a second widget: + +- preserve `codeburn status --format menubar-json`; +- keep Swift `MenubarPayload` backward-compatible; +- show real token/cost data in the menubar popover; +- prevent duplicate project rows when multiple sources resolve to the same project name. + +## Data Checks + +Run these commands after building: + +```sh +npm run build +node dist/cli.js status --format menubar-json --period today --no-optimize +node dist/cli.js status --format menubar-json --period week --no-optimize +node dist/cli.js status --format menubar-json --period month --no-optimize +``` + +Expected: + +- `current.cost`, `current.calls`, `current.inputTokens`, and `current.outputTokens` are numeric and not `NaN`; +- Codex-specific data appears through `current.providers.codex` and `current.codexCredits`; +- `current.topProjects` has no duplicate `name` values; +- each project row preserves `inputTokens`, `outputTokens`, `reasoningTokens`, cache token fields, and `totalTokens`. + +## Smoke Check + +```sh +CODEBURN_MENUBAR_SMOKE_OUTPUT=/tmp/codeburn-menubar-smoke mac/Scripts/smoke-popover.sh +``` + +Expected files: + +- `/tmp/codeburn-menubar-smoke/report.json`; +- `/tmp/codeburn-menubar-smoke/popover-today-trend.png`. + +Expected report values: + +- `ok: true`; +- `selectedProvider: All`; +- `selectedPeriod: Today`; +- `currentCalls`, `currentInputTokens`, and `currentOutputTokens` are real numeric values; +- `topProjectDuplicateNames` is an empty array. + +## Verification Log + +Populate this table during final verification. + +| Check | Result | Evidence | +| --- | --- | --- | +| Targeted tests | Pass | `npm test -- tests/menubar-json.test.ts tests/providers/codex.test.ts tests/usage-aggregator.test.ts`: 3 files, 38 tests passed | +| Full test suite | Pass with sequential rerun for known slow CLI tests | `npm test`: 102 files passed; 4 files timed out at 5000ms in parallel. `npx vitest tests/cli-export-date-range.test.ts tests/cli-json-daily.test.ts tests/cli-status-menubar.test.ts tests/parser-proxy-codex-only.test.ts --testTimeout 30000 --fileParallelism=false`: 4 files, 8 tests passed | +| Build | Pass | `npm run build`: passed; `openrouter skipped: fetch failed` and Vite chunk-size warning were non-fatal. Generated pricing snapshot drift was restored before commit | +| Swift build | Pass | `cd mac && swift build`: build complete | +| Swift tests | Blocked by local toolchain | `cd mac && swift test`: failed with `no such module 'Testing'` before exercising task changes | +| Menubar smoke | Pass | `mac/Scripts/smoke-popover.sh /Users/vadimirrosman/Documents/Codex/2026-06-23/new-chat-4/outputs/codeburn-menubar-smoke-rebased-20260623T1432Z`: `ok: true`, screenshot captured, `topProjectDuplicateNames: []` | +| CLI data check | Pass | `node dist/cli.js status --format menubar-json --period week --no-optimize`: cost `94.629252`, calls `138`, input `892306`, output `82204`, Codex credits `394.28855`, duplicate projects `[]`; `month` matched the same June 2026 data. `today` was valid but zero usage on 2026-06-23 | +| Final diff check | Pass | `git diff --check` | diff --git a/mac/Scripts/smoke-popover.sh b/mac/Scripts/smoke-popover.sh new file mode 100755 index 00000000..f2dff24a --- /dev/null +++ b/mac/Scripts/smoke-popover.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUT_DIR="${1:-/tmp/codeburn-menubar-smoke-$(date +%Y%m%d-%H%M%S)}" +TMP_CLI_DIR="$(mktemp -d /tmp/codeburn-cli.XXXXXX)" + +cleanup() { + rm -rf "$TMP_CLI_DIR" +} +trap cleanup EXIT + +mkdir -p "$OUT_DIR" + +if [[ ! -x "$ROOT_DIR/dist/cli.js" ]]; then + (cd "$ROOT_DIR" && npm run build) +fi + +ln -sf "$ROOT_DIR/dist/cli.js" "$TMP_CLI_DIR/cli.js" + +( + cd "$ROOT_DIR/mac" + CODEBURN_ALLOW_DEV_BIN=1 \ + CODEBURN_BIN="node $TMP_CLI_DIR/cli.js" \ + CODEBURN_MENUBAR_SMOKE_OUTPUT="$OUT_DIR" \ + swift run +) + +echo "Smoke report: $OUT_DIR/report.json" diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index dd31cb9d..3f82d24f 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -13,6 +13,12 @@ private let popoverWidth: CGFloat = 360 private let popoverHeight: CGFloat = 660 private let menubarTitleFontSize: CGFloat = 13 +enum MenubarSmokeError: Error { + case missingPopoverView + case invalidPopoverBounds + case screenshotEncodingFailed +} + @main struct CodeBurnApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate @@ -96,6 +102,108 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { registerLoginItemIfNeeded() observeSubscriptionDisconnect() Task { await updateChecker.checkIfNeeded() } + runMenubarSmokeIfRequested() + } + + private func runMenubarSmokeIfRequested() { + guard let output = ProcessInfo.processInfo.environment["CODEBURN_MENUBAR_SMOKE_OUTPUT"], + !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let outputDir = URL(fileURLWithPath: output, isDirectory: true) + Task { [weak self] in + guard let self else { return } + await self.runMenubarSmoke(outputDir: outputDir) + } + } + + private func smokeInsightMode() -> InsightMode { + guard let requested = ProcessInfo.processInfo.environment["CODEBURN_MENUBAR_SMOKE_INSIGHT"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !requested.isEmpty else { return .trend } + return InsightMode.allCases.first { $0.rawValue.caseInsensitiveCompare(requested) == .orderedSame } ?? .trend + } + + private func runMenubarSmoke(outputDir: URL) async { + do { + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + store.resetRefreshState(clearCache: true) + store.selectedProvider = .all + store.selectedPeriod = .today + store.selectedDays = [] + let smokeInsight = smokeInsightMode() + store.selectedInsight = smokeInsight + await store.refresh(includeOptimize: false, force: true, showLoading: false) + refreshStatusButton() + showPopoverForSmoke() + try await Task.sleep(nanoseconds: 900_000_000) + let screenshotName = "popover-today-\(smokeInsight.rawValue.lowercased()).png" + let screenshotURL = outputDir.appendingPathComponent(screenshotName) + try capturePopoverScreenshot(to: screenshotURL) + try writeMenubarSmokeReport(to: outputDir.appendingPathComponent("report.json"), screenshotURL: screenshotURL) + } catch { + writeMenubarSmokeFailure(to: outputDir, error: error) + NSLog("CodeBurn: menubar smoke failed: \(error)") + } + + if ProcessInfo.processInfo.environment["CODEBURN_MENUBAR_SMOKE_KEEP_OPEN"] != "1" { + NSApp.terminate(nil) + } + } + + private func showPopoverForSmoke() { + guard let button = statusItem.button else { return } + if !popover.isShown { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + } + popover.contentViewController?.view.displayIfNeeded() + } + + private func writeMenubarSmokeFailure(to outputDir: URL, error: Error) { + let payload: [String: Any] = ["ok": false, "error": String(describing: error)] + let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + try? data?.write(to: outputDir.appendingPathComponent("report.json")) + } + + private func writeMenubarSmokeReport(to url: URL, screenshotURL: URL) throws { + let payload = store.payload + let duplicateProjectNames = Dictionary(grouping: payload.current.topProjects, by: \.name) + .filter { $0.value.count > 1 } + .map(\.key) + .sorted() + let report: [String: Any] = [ + "ok": true, + "selectedProvider": store.selectedProvider.rawValue, + "selectedPeriod": store.selectedPeriod.rawValue, + "selectedInsight": store.selectedInsight.rawValue, + "currentLabel": payload.current.label, + "currentCost": payload.current.cost, + "currentCalls": payload.current.calls, + "currentSessions": payload.current.sessions, + "currentInputTokens": payload.current.inputTokens, + "currentOutputTokens": payload.current.outputTokens, + "currentCodexCredits": payload.current.codexCredits ?? 0, + "topProjectCount": payload.current.topProjects.count, + "topProjectDuplicateNames": duplicateProjectNames, + "screenshot": screenshotURL.path, + ] + let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url) + } + + private func capturePopoverScreenshot(to url: URL) throws { + guard let view = popover.contentViewController?.view else { + throw MenubarSmokeError.missingPopoverView + } + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + let bounds = view.bounds + guard !bounds.isEmpty else { throw MenubarSmokeError.invalidPopoverBounds } + guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { + throw MenubarSmokeError.screenshotEncodingFailed + } + view.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { + throw MenubarSmokeError.screenshotEncodingFailed + } + try png.write(to: url) } private func setupWakeObservers() { diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 50507c43..a9b29e72 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -252,6 +252,7 @@ struct SessionDetailEntry: Codable, Sendable { let calls: Int let inputTokens: Int let outputTokens: Int + let reasoningTokens: Int let date: String let models: [SessionModelEntry] @@ -262,12 +263,13 @@ struct SessionDetailEntry: Codable, Sendable { calls = try c.decode(Int.self, forKey: .calls) inputTokens = try c.decode(Int.self, forKey: .inputTokens) outputTokens = try c.decode(Int.self, forKey: .outputTokens) + reasoningTokens = try c.decodeIfPresent(Int.self, forKey: .reasoningTokens) ?? 0 date = try c.decode(String.self, forKey: .date) models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? [] } private enum CodingKeys: String, CodingKey { - case cost, savingsUSD, calls, inputTokens, outputTokens, date, models + case cost, savingsUSD, calls, inputTokens, outputTokens, reasoningTokens, date, models } } @@ -276,6 +278,12 @@ struct ProjectEntry: Codable, Sendable { let cost: Double let savingsUSD: Double let sessions: Int + let inputTokens: Int + let outputTokens: Int + let reasoningTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + let totalTokens: Int let avgCostPerSession: Double let sessionDetails: [SessionDetailEntry] @@ -285,12 +293,19 @@ struct ProjectEntry: Codable, Sendable { cost = try c.decode(Double.self, forKey: .cost) savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 sessions = try c.decode(Int.self, forKey: .sessions) + inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0 + outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0 + reasoningTokens = try c.decodeIfPresent(Int.self, forKey: .reasoningTokens) ?? 0 + cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) ?? 0 + cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens) ?? 0 + totalTokens = try c.decodeIfPresent(Int.self, forKey: .totalTokens) ?? + (inputTokens + outputTokens + reasoningTokens + cacheReadTokens + cacheWriteTokens) avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession) sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? [] } private enum CodingKeys: String, CodingKey { - case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails + case name, cost, savingsUSD, sessions, inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWriteTokens, totalTokens, avgCostPerSession, sessionDetails } } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 9fe3bec6..537d0cd3 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -21,7 +21,7 @@ export type PeriodData = { codexCredits?: number categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }> - projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }> + projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; reasoningTokens?: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }> modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }> topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }> } @@ -116,6 +116,12 @@ export type MenubarPayload = { cost: number savingsUSD: number sessions: number + inputTokens: number + outputTokens: number + reasoningTokens: number + cacheReadTokens: number + cacheWriteTokens: number + totalTokens: number avgCostPerSession: number sessionDetails: Array<{ cost: number @@ -123,6 +129,7 @@ export type MenubarPayload = { calls: number inputTokens: number outputTokens: number + reasoningTokens: number date: string models: Array<{ name: string; cost: number; savingsUSD: number }> }> @@ -256,8 +263,40 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h } function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] { - return (projects ?? []) - .filter(p => p.cost > 0 || p.savingsUSD > 0) + const grouped = new Map[number]>() + for (const project of projects ?? []) { + if (project.sessions <= 0) continue + const existing = grouped.get(project.name) + if (existing) { + existing.cost += project.cost + existing.savingsUSD += project.savingsUSD + existing.sessions += project.sessions + existing.inputTokens = (existing.inputTokens ?? 0) + (project.inputTokens ?? 0) + existing.outputTokens = (existing.outputTokens ?? 0) + (project.outputTokens ?? 0) + existing.reasoningTokens = (existing.reasoningTokens ?? 0) + (project.reasoningTokens ?? 0) + existing.cacheReadTokens = (existing.cacheReadTokens ?? 0) + (project.cacheReadTokens ?? 0) + existing.cacheWriteTokens = (existing.cacheWriteTokens ?? 0) + (project.cacheWriteTokens ?? 0) + existing.sessionDetails = [ + ...(existing.sessionDetails ?? []), + ...(project.sessionDetails ?? []), + ] + } else { + grouped.set(project.name, { + ...project, + sessionDetails: [...(project.sessionDetails ?? [])], + }) + } + } + return [...grouped.values()] + .filter(p => + p.cost > 0 || + p.savingsUSD > 0 || + (p.inputTokens ?? 0) > 0 || + (p.outputTokens ?? 0) > 0 || + (p.reasoningTokens ?? 0) > 0 || + (p.cacheReadTokens ?? 0) > 0 || + (p.cacheWriteTokens ?? 0) > 0 + ) .sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)) .slice(0, TOP_PROJECTS_LIMIT) .map(p => ({ @@ -265,16 +304,25 @@ function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['cur cost: p.cost, savingsUSD: p.savingsUSD, sessions: p.sessions, + inputTokens: p.inputTokens ?? 0, + outputTokens: p.outputTokens ?? 0, + reasoningTokens: p.reasoningTokens ?? 0, + cacheReadTokens: p.cacheReadTokens ?? 0, + cacheWriteTokens: p.cacheWriteTokens ?? 0, + totalTokens: (p.inputTokens ?? 0) + (p.outputTokens ?? 0) + (p.reasoningTokens ?? 0) + (p.cacheReadTokens ?? 0) + (p.cacheWriteTokens ?? 0), avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0, - sessionDetails: (p.sessionDetails ?? []).map(s => ({ - cost: s.cost, - savingsUSD: s.savingsUSD, - calls: s.calls, - inputTokens: s.inputTokens, - outputTokens: s.outputTokens, - date: s.date, - models: s.models, - })), + sessionDetails: (p.sessionDetails ?? []) + .sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)) + .map(s => ({ + cost: s.cost, + savingsUSD: s.savingsUSD, + calls: s.calls, + inputTokens: s.inputTokens, + outputTokens: s.outputTokens, + reasoningTokens: s.reasoningTokens ?? 0, + date: s.date, + models: s.models, + })), })) } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index ad15d0ed..f28e37bb 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -281,6 +281,11 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: cost: p.totalCostUSD, savingsUSD: p.totalSavingsUSD, sessions: p.sessions.length, + inputTokens: p.sessions.reduce((s, session) => s + session.totalInputTokens, 0), + outputTokens: p.sessions.reduce((s, session) => s + session.totalOutputTokens, 0), + reasoningTokens: p.sessions.reduce((s, session) => s + session.totalReasoningTokens, 0), + cacheReadTokens: p.sessions.reduce((s, session) => s + session.totalCacheReadTokens, 0), + cacheWriteTokens: p.sessions.reduce((s, session) => s + session.totalCacheWriteTokens, 0), sessionDetails: [...p.sessions] .sort((a, b) => b.totalCostUSD - a.totalCostUSD) .slice(0, 10) @@ -290,6 +295,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: calls: s.apiCalls, inputTokens: s.totalInputTokens, outputTokens: s.totalOutputTokens, + reasoningTokens: s.totalReasoningTokens, date: s.firstTimestamp?.split('T')[0] ?? '', models: Object.entries(s.modelBreakdown) .map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD })) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index f7493d0b..8ea5ff4a 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -231,4 +231,74 @@ describe('buildMenubarPayload', () => { const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) expect(payload.current.providers).toEqual({ claude: 76.45 }) }) + + it('groups duplicate top project rows and preserves real token totals', () => { + const period: PeriodData = { + ...emptyPeriod('Today'), + projects: [ + { + name: 'codeburn', + cost: 4, + savingsUSD: 0, + sessions: 1, + inputTokens: 100, + outputTokens: 20, + reasoningTokens: 7, + cacheReadTokens: 300, + cacheWriteTokens: 10, + sessionDetails: [ + { + cost: 4, + savingsUSD: 0, + calls: 2, + inputTokens: 100, + outputTokens: 20, + reasoningTokens: 7, + date: '2026-06-23', + models: [{ name: 'gpt-5.1-codex', cost: 4, savingsUSD: 0 }], + }, + ], + }, + { + name: 'codeburn', + cost: 2, + savingsUSD: 0, + sessions: 2, + inputTokens: 50, + outputTokens: 15, + reasoningTokens: 3, + cacheReadTokens: 80, + cacheWriteTokens: 5, + sessionDetails: [ + { + cost: 2, + savingsUSD: 0, + calls: 1, + inputTokens: 50, + outputTokens: 15, + reasoningTokens: 3, + date: '2026-06-23', + models: [{ name: 'gpt-5.1-codex', cost: 2, savingsUSD: 0 }], + }, + ], + }, + ], + } + + const payload = buildMenubarPayload(period, [], null) + + expect(payload.current.topProjects).toHaveLength(1) + expect(payload.current.topProjects[0]).toMatchObject({ + name: 'codeburn', + cost: 6, + sessions: 3, + inputTokens: 150, + outputTokens: 35, + reasoningTokens: 10, + cacheReadTokens: 380, + cacheWriteTokens: 15, + totalTokens: 590, + }) + expect(payload.current.topProjects[0]!.sessionDetails).toHaveLength(2) + }) })