Files
orca/src/main/opencode-usage/scanner.test.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

328 lines
9.3 KiB
TypeScript

/* eslint-disable max-lines -- Why: OpenCode scanner tests cover multiple DB schema generations and attribution boundaries together so parser regressions stay auditable. */
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import Database from '../sqlite/sync-database'
import {
attributeOpenCodeUsageEvent,
parseOpenCodeUsageDatabase,
parseOpenCodeUsageRow
} from './scanner'
const WORKTREE = '/workspace/repo'
let tempDirs: string[] = []
function createTempDb(): { db: Database.Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-'))
tempDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
function worktrees() {
return [
{
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
path: WORKTREE,
displayName: 'Repo',
canonicalPath: WORKTREE
}
]
}
function usageEvent(cwd: string) {
return {
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
cwd,
model: 'anthropic/claude-sonnet-4-5',
estimatedCostUsd: 0.012,
inputTokens: 100,
cachedInputTokens: 10,
outputTokens: 25,
reasoningOutputTokens: 10,
totalTokens: 125
}
}
describe('parseOpenCodeUsageRow', () => {
it('reads assistant message tokens, cost, model, cwd, and timestamp', () => {
const parsed = parseOpenCodeUsageRow({
id: 'message-1',
session_id: 'session-1',
time_created: 1_777_777_700_000,
time_updated: null,
directory: null,
title: null,
worktree: null,
session_model: null,
data: JSON.stringify({
providerID: 'anthropic',
modelID: 'claude-sonnet-4-5',
path: { cwd: `${WORKTREE}/packages/app` },
cost: 0.0123,
tokens: {
input: 1000,
output: 250,
reasoning: 100,
total: 1350,
cache: { read: 400, write: 25 }
},
time: {
completed: 1_777_777_800_000
}
})
})
expect(parsed).toEqual({
sessionId: 'session-1',
timestamp: new Date(1_777_777_800_000).toISOString(),
cwd: `${WORKTREE}/packages/app`,
model: 'anthropic/claude-sonnet-4-5',
estimatedCostUsd: 0.0123,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1350
})
})
})
describe('attributeOpenCodeUsageEvent', () => {
it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => {
const attributed = await attributeOpenCodeUsageEvent(
usageEvent(`${WORKTREE}/..fixtures/session`),
worktrees()
)
expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo')
expect(attributed?.projectLabel).toBe('Repo')
expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo')
})
it('does not attribute true parent-directory escapes to the worktree', async () => {
const attributed = await attributeOpenCodeUsageEvent(
usageEvent(`${WORKTREE}/../other/session`),
worktrees()
)
expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session')
expect(attributed?.worktreeId).toBeNull()
})
it('does not treat different Windows drives as containing paths', async () => {
const attributed = await attributeOpenCodeUsageEvent(usageEvent('D:\\other\\repo'), [
{
repoId: 'repo-1',
worktreeId: 'repo-1::C:\\repo',
path: 'C:\\repo',
displayName: 'Repo',
canonicalPath: 'C:\\repo'
}
])
expect(attributed?.projectKey).toBe('cwd:d:/other/repo')
expect(attributed?.worktreeId).toBeNull()
})
})
describe('parseOpenCodeUsageDatabase', () => {
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
it('uses materialized session token totals when the OpenCode DB has them', async () => {
const { db, path } = createTempDb()
db.exec(`
CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT,
directory TEXT,
title TEXT,
model TEXT,
cost REAL,
tokens_input INTEGER,
tokens_output INTEGER,
tokens_reasoning INTEGER,
tokens_cache_read INTEGER,
time_created INTEGER,
time_updated INTEGER
);
`)
db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE)
db.prepare(
`INSERT INTO session (
id, project_id, directory, title, model, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
time_created, time_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
'session-1',
'project-1',
`${WORKTREE}/packages/app`,
'Build feature',
JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }),
0.06,
1000,
500,
100,
250,
1_777_777_700_000,
1_777_777_800_000
)
db.close()
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
expect(parsed.sessions).toHaveLength(1)
expect(parsed.sessions[0]).toMatchObject({
sessionId: 'session-1',
primaryModel: 'anthropic/claude-sonnet-4-5',
primaryProjectLabel: 'Repo',
eventCount: 1,
totalInputTokens: 1000,
totalCachedInputTokens: 250,
totalOutputTokens: 500,
totalReasoningOutputTokens: 100,
totalTokens: 1600,
estimatedCostUsd: 0.06
})
expect(parsed.dailyAggregates).toEqual([
expect.objectContaining({
projectLabel: 'Repo',
inputTokens: 1000,
cachedInputTokens: 250,
outputTokens: 500,
reasoningOutputTokens: 100,
totalTokens: 1600,
estimatedCostUsd: 0.06
})
])
})
it('supports session_message tables without a type column', async () => {
const { db, path } = createTempDb()
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT,
title TEXT,
time_created INTEGER,
time_updated INTEGER
);
CREATE TABLE session_message (
id TEXT PRIMARY KEY,
session_id TEXT,
time_created INTEGER,
time_updated INTEGER,
data TEXT
);
`)
db.prepare(
'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)'
).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000)
db.prepare(
'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
).run(
'message-1',
'session-1',
1_777_777_700_000,
1_777_777_800_000,
JSON.stringify({
providerID: 'openai',
modelID: 'gpt-5.5',
cost: 0.03,
tokens: {
input: 800,
output: 200,
reasoning: 50,
cache: { read: 100, write: 0 }
}
})
)
db.close()
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
expect(parsed.sessions[0]).toMatchObject({
primaryModel: 'openai/gpt-5.5',
primaryProjectLabel: 'Repo',
totalTokens: 1050,
estimatedCostUsd: 0.03
})
})
it('prefers session_message rows over legacy message rows to avoid double counting', async () => {
const { db, path } = createTempDb()
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT,
title TEXT,
time_created INTEGER,
time_updated INTEGER
);
CREATE TABLE session_message (
id TEXT PRIMARY KEY,
session_id TEXT,
type TEXT,
time_created INTEGER,
time_updated INTEGER,
data TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT,
time_created INTEGER,
time_updated INTEGER,
data TEXT
);
`)
db.prepare(
'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)'
).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000)
db.prepare(
'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)'
).run(
'session-message-1',
'session-1',
'assistant',
1_777_777_700_000,
1_777_777_800_000,
JSON.stringify({
providerID: 'openai',
modelID: 'gpt-5.5',
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } }
})
)
db.prepare(
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
).run(
'legacy-message-1',
'session-1',
1_777_777_700_000,
1_777_777_800_000,
JSON.stringify({
role: 'assistant',
providerID: 'openai',
modelID: 'gpt-5.5',
tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } }
})
)
db.close()
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
expect(parsed.sessions[0]?.totalTokens).toBe(120)
expect(parsed.sessions[0]?.eventCount).toBe(1)
})
})