f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
423 lines
10 KiB
Go
423 lines
10 KiB
Go
package parser
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const devinSyntheticSessionsSchema = `
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
working_directory TEXT,
|
|
model TEXT,
|
|
created_at INTEGER,
|
|
last_activity_at INTEGER,
|
|
workspace_json TEXT,
|
|
metadata_json TEXT,
|
|
hidden INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
`
|
|
|
|
const devinSyntheticMessageNodesSchema = `
|
|
CREATE TABLE message_nodes (
|
|
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
node_id INTEGER NOT NULL,
|
|
parent_node_id INTEGER,
|
|
chat_message TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
metadata TEXT
|
|
);
|
|
`
|
|
|
|
type devinSessionRow struct {
|
|
ID string
|
|
Title string
|
|
WorkingDirectory string
|
|
Model string
|
|
CreatedAtMillis *int64
|
|
LastActivityMillis *int64
|
|
WorkspaceJSON string
|
|
MetadataJSON string
|
|
Hidden bool
|
|
}
|
|
|
|
type devinTestFixture struct {
|
|
Root string
|
|
CLIDir string
|
|
TranscriptsDir string
|
|
DBPath string
|
|
}
|
|
|
|
type devinSyntheticMessageNodeRow struct {
|
|
SessionID string
|
|
NodeID int64
|
|
ParentNodeID *int64
|
|
ChatMessage string
|
|
CreatedAtMillis int64
|
|
MetadataJSON string
|
|
}
|
|
|
|
func newDevinTestFixture(t *testing.T, rows ...devinSessionRow) *devinTestFixture {
|
|
t.Helper()
|
|
|
|
root := t.TempDir()
|
|
cliDir := filepath.Join(root, "cli")
|
|
transcriptsDir := filepath.Join(cliDir, "transcripts")
|
|
require.NoError(t, os.MkdirAll(transcriptsDir, 0o755))
|
|
|
|
fixture := &devinTestFixture{
|
|
Root: root,
|
|
CLIDir: cliDir,
|
|
TranscriptsDir: transcriptsDir,
|
|
DBPath: filepath.Join(cliDir, devinDBFilename),
|
|
}
|
|
|
|
initDevinTestDB(t, fixture.DBPath)
|
|
execDevinTestSQL(t, fixture.DBPath, devinSyntheticSessionsSchema)
|
|
execDevinTestSQL(t, fixture.DBPath, devinSyntheticMessageNodesSchema)
|
|
for _, row := range rows {
|
|
insertDevinSessionRow(t, fixture.DBPath, row)
|
|
}
|
|
|
|
return fixture
|
|
}
|
|
|
|
func initDevinTestDB(t *testing.T, dbPath string) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Close())
|
|
}
|
|
|
|
func insertDevinSessionRow(t *testing.T, dbPath string, row devinSessionRow) {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
defer db.Close()
|
|
|
|
_, err = db.Exec(`
|
|
INSERT INTO sessions (
|
|
id,
|
|
title,
|
|
working_directory,
|
|
model,
|
|
created_at,
|
|
last_activity_at,
|
|
workspace_json,
|
|
metadata_json,
|
|
hidden
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
row.ID,
|
|
row.Title,
|
|
row.WorkingDirectory,
|
|
row.Model,
|
|
devinNullableMillis(row.CreatedAtMillis),
|
|
devinNullableMillis(row.LastActivityMillis),
|
|
devinNullableString(row.WorkspaceJSON),
|
|
devinNullableString(row.MetadataJSON),
|
|
row.Hidden,
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func devinNullableMillis(ms *int64) any {
|
|
if ms == nil {
|
|
return nil
|
|
}
|
|
return *ms
|
|
}
|
|
|
|
func devinNullableString(value string) any {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (f *devinTestFixture) writeTranscript(t *testing.T, sessionID, transcript string) string {
|
|
t.Helper()
|
|
transcriptPath := filepath.Join(f.TranscriptsDir, sessionID+".json")
|
|
require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o644))
|
|
return transcriptPath
|
|
}
|
|
|
|
func (f *devinTestFixture) sessionVirtualPath(sessionID string) string {
|
|
return VirtualSourcePath(f.DBPath, sessionID)
|
|
}
|
|
|
|
func (f *devinTestFixture) insertMessageNodes(t *testing.T, rows ...devinSyntheticMessageNodeRow) {
|
|
t.Helper()
|
|
for _, row := range rows {
|
|
insertDevinMessageNodeRow(t, f.DBPath, row)
|
|
}
|
|
}
|
|
|
|
func newDevinSessionFixture(t *testing.T, row devinSessionRow, transcript string) (string, string) {
|
|
t.Helper()
|
|
fixture := newDevinTestFixture(t, row)
|
|
return fixture.DBPath, fixture.writeTranscript(t, row.ID, transcript)
|
|
}
|
|
|
|
func execDevinTestSQL(t *testing.T, dbPath, query string) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
defer db.Close()
|
|
_, err = db.Exec(query)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func insertDevinMessageNodeRow(t *testing.T, dbPath string, row devinSyntheticMessageNodeRow) {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
defer db.Close()
|
|
|
|
_, err = db.Exec(`
|
|
INSERT INTO message_nodes (
|
|
session_id,
|
|
node_id,
|
|
parent_node_id,
|
|
chat_message,
|
|
created_at,
|
|
metadata
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
`,
|
|
row.SessionID,
|
|
row.NodeID,
|
|
devinNullableInt64(row.ParentNodeID),
|
|
row.ChatMessage,
|
|
row.CreatedAtMillis,
|
|
devinNullableString(row.MetadataJSON),
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func devinNullableInt64(value *int64) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func devinMetaIDs(metas []DevinSessionMeta) []string {
|
|
ids := make([]string, 0, len(metas))
|
|
for _, meta := range metas {
|
|
ids = append(ids, meta.RawSessionID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// parseChatGPTExport parses a ChatGPT export through the ChatGPT import-only
|
|
// provider. It is the test harness replacement for the former
|
|
// ParseChatGPTExport free function.
|
|
func parseChatGPTExport(
|
|
dir string,
|
|
assets AssetResolver,
|
|
onConversation func(ParseResult) error,
|
|
) error {
|
|
p, ok := NewProvider(AgentChatGPT, ProviderConfig{})
|
|
if !ok {
|
|
return fmt.Errorf("chatgpt provider unavailable")
|
|
}
|
|
exporter, ok := p.(ChatGPTExportParser)
|
|
if !ok {
|
|
return fmt.Errorf("chatgpt provider does not support exports")
|
|
}
|
|
return exporter.ParseChatGPTExport(dir, assets, onConversation)
|
|
}
|
|
|
|
// parseClaudeAIExport parses a Claude.ai export through the Claude.ai
|
|
// import-only provider. It is the test harness replacement for the former
|
|
// ParseClaudeAIExport free function.
|
|
func parseClaudeAIExport(
|
|
r io.Reader,
|
|
onConversation func(ParseResult) error,
|
|
) error {
|
|
p, ok := NewProvider(AgentClaudeAI, ProviderConfig{})
|
|
if !ok {
|
|
return fmt.Errorf("claude.ai provider unavailable")
|
|
}
|
|
exporter, ok := p.(ClaudeAIExportParser)
|
|
if !ok {
|
|
return fmt.Errorf("claude.ai provider does not support exports")
|
|
}
|
|
return exporter.ParseClaudeAIExport(r, onConversation)
|
|
}
|
|
|
|
// Timestamp constants for test data.
|
|
const (
|
|
tsZero = "2024-01-01T00:00:00Z"
|
|
tsZeroS1 = "2024-01-01T00:00:01Z"
|
|
tsZeroS2 = "2024-01-01T00:00:02Z"
|
|
tsEarly = "2024-01-01T10:00:00Z"
|
|
tsEarlyS1 = "2024-01-01T10:00:01Z"
|
|
tsEarlyS5 = "2024-01-01T10:00:05Z"
|
|
tsLate = "2024-01-01T10:01:00Z"
|
|
tsLateS5 = "2024-01-01T10:01:05Z"
|
|
)
|
|
|
|
// Parsed time.Time values used as expected results in
|
|
// timestamp parsing tests.
|
|
var testJan15_1030UTC = time.Date(
|
|
2024, 1, 15, 10, 30, 0, 0, time.UTC,
|
|
)
|
|
|
|
// --- Data Generators ---
|
|
|
|
func generateLargeString(size int) string {
|
|
return strings.Repeat("x", size)
|
|
}
|
|
|
|
// --- Assertions ---
|
|
|
|
func assertSessionMeta(t *testing.T, s *ParsedSession, wantID, wantProject string, wantAgent AgentType) {
|
|
t.Helper()
|
|
require.NotNil(t, s, "session is nil")
|
|
assert.Equal(t, wantID, s.ID, "session ID")
|
|
assert.Equal(t, wantProject, s.Project, "project")
|
|
assert.Equal(t, wantAgent, s.Agent, "agent")
|
|
}
|
|
|
|
func assertMessage(t *testing.T, m ParsedMessage, wantRole RoleType, wantContentSnippet string) {
|
|
t.Helper()
|
|
assert.Equal(t, wantRole, m.Role, "role")
|
|
if wantContentSnippet != "" {
|
|
assert.Contains(t, m.Content, wantContentSnippet)
|
|
}
|
|
}
|
|
|
|
func assertMessageCount(t *testing.T, count, want int) {
|
|
t.Helper()
|
|
require.Equal(t, want, count, "message count")
|
|
}
|
|
|
|
func assertTimestamp(t *testing.T, got time.Time, want time.Time) {
|
|
t.Helper()
|
|
assert.True(t, got.Equal(want), "timestamp = %v, want %v", got, want)
|
|
}
|
|
|
|
func assertZeroTimestamp(
|
|
t *testing.T, ts time.Time, label string,
|
|
) {
|
|
t.Helper()
|
|
assert.True(t, ts.IsZero(), "%s = %v, want zero", label, ts)
|
|
}
|
|
|
|
// captureLog redirects log output to a buffer for the
|
|
// duration of the test and restores it on cleanup.
|
|
func captureLog(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
old := log.Writer()
|
|
log.SetOutput(&buf)
|
|
t.Cleanup(func() { log.SetOutput(old) })
|
|
return &buf
|
|
}
|
|
|
|
func assertLogContains(
|
|
t *testing.T, buf *bytes.Buffer, substrs ...string,
|
|
) {
|
|
t.Helper()
|
|
got := buf.String()
|
|
for _, s := range substrs {
|
|
assert.Contains(t, got, s, "log missing substring %q", s)
|
|
}
|
|
}
|
|
|
|
func assertLogNotContains(
|
|
t *testing.T, buf *bytes.Buffer, substrs ...string,
|
|
) {
|
|
t.Helper()
|
|
got := buf.String()
|
|
for _, s := range substrs {
|
|
assert.NotContains(t, got, s, "log should not contain substring %q", s)
|
|
}
|
|
}
|
|
|
|
func assertLogEmpty(t *testing.T, buf *bytes.Buffer) {
|
|
t.Helper()
|
|
assert.Zero(t, buf.Len(), "expected no log output, got: %q", buf.String())
|
|
}
|
|
|
|
func assertToolCallField(t *testing.T, i int, field, got, want string) {
|
|
t.Helper()
|
|
assert.Equal(t, want, got, fmt.Sprintf("tool_calls[%d].%s", i, field))
|
|
}
|
|
|
|
func assertToolCall(t *testing.T, i int, got, want ParsedToolCall) {
|
|
t.Helper()
|
|
assertToolCallField(t, i, "ToolName", got.ToolName, want.ToolName)
|
|
assertToolCallField(t, i, "Category", got.Category, want.Category)
|
|
if want.ToolUseID != "" {
|
|
assertToolCallField(t, i, "ToolUseID", got.ToolUseID, want.ToolUseID)
|
|
}
|
|
if want.InputJSON != "" {
|
|
assertToolCallField(t, i, "InputJSON", got.InputJSON, want.InputJSON)
|
|
}
|
|
if want.SkillName != "" {
|
|
assertToolCallField(t, i, "SkillName", got.SkillName, want.SkillName)
|
|
}
|
|
assertToolCallField(t, i, "SubagentSessionID", got.SubagentSessionID, want.SubagentSessionID)
|
|
}
|
|
|
|
func assertToolCalls(
|
|
t *testing.T, got, want []ParsedToolCall,
|
|
) {
|
|
t.Helper()
|
|
if !assert.Equal(t, len(want), len(got), "tool calls count") {
|
|
return
|
|
}
|
|
for i := range want {
|
|
assertToolCall(t, i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
func parseClaudeTestFile(
|
|
t *testing.T, name, content, project string,
|
|
) (ParsedSession, []ParsedMessage) {
|
|
t.Helper()
|
|
path := createTestFile(t, name, content)
|
|
results, err := parseClaudeSession(
|
|
path, project, "local",
|
|
)
|
|
require.NoError(t, err, "parseClaudeSession")
|
|
require.NotEmpty(t, results, "parseClaudeSession returned no results")
|
|
return results[0].Session, results[0].Messages
|
|
}
|
|
|
|
// parseClaudeSession parses a standalone Claude transcript through the Claude
|
|
// provider's upload entry point, honoring the explicit project. It is the
|
|
// test harness replacement for the former ParseClaudeSession free function,
|
|
// exercising the same provider-owned parse body that production uploads use.
|
|
func parseClaudeSession(
|
|
path, project, machine string,
|
|
) ([]ParseResult, error) {
|
|
provider, ok := NewProvider(AgentClaude, ProviderConfig{Machine: machine})
|
|
if !ok {
|
|
return nil, fmt.Errorf("claude provider unavailable")
|
|
}
|
|
uploader, ok := provider.(ClaudeUploadParser)
|
|
if !ok {
|
|
return nil, fmt.Errorf("claude provider does not support upload parsing")
|
|
}
|
|
return uploader.ParseUploadedTranscript(path, project, machine)
|
|
}
|