Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

666 lines
17 KiB
Go

package sync_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
stdsync "sync"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/sync"
)
// Timestamp constants for test data.
const (
tsZero = "2024-01-01T00:00:00Z"
tsZeroS1 = "2024-01-01T00:00:01Z"
tsZeroS5 = "2024-01-01T00:00:05Z"
tsEarly = "2024-01-01T10:00:00Z"
tsEarlyS1 = "2024-01-01T10:00:01Z"
tsEarlyS5 = "2024-01-01T10:00:05Z"
)
// --- Assertion Helpers ---
func assertSessionState(t *testing.T, database *db.DB, sessionID string, check func(*db.Session)) {
t.Helper()
sess, err := database.GetSession(context.Background(), sessionID)
require.NoError(t, err, "GetSession(%q)", sessionID)
require.NotNil(t, sess, "Session %q not found", sessionID)
if check != nil {
check(sess)
}
}
func assertSessionMessageCount(t *testing.T, database *db.DB, sessionID string, want int) {
t.Helper()
assertSessionState(t, database, sessionID, func(sess *db.Session) {
assert.Equal(t, want, sess.MessageCount, "session %q message_count", sessionID)
})
}
func assertSessionProject(t *testing.T, database *db.DB, sessionID string, want string) {
t.Helper()
assertSessionState(t, database, sessionID, func(sess *db.Session) {
assert.Equal(t, want, sess.Project, "session %q project", sessionID)
})
}
func runSyncAndAssert(t *testing.T, engine *sync.Engine, want sync.SyncStats) sync.SyncStats {
t.Helper()
stats := engine.SyncAll(context.Background(), nil)
diff := cmp.Diff(want, stats,
cmpopts.IgnoreUnexported(sync.SyncStats{}),
)
require.Empty(t, diff, "SyncAll() mismatch (-want +got):\n%s", diff)
return stats
}
// assertResyncRoundTrip clears file_mtime to force a resync,
// runs SyncSingleSession, and verifies the session is stored
// and a subsequent SyncAll skips.
func (e *testEnv) assertResyncRoundTrip(
t *testing.T, sessionID string,
) {
t.Helper()
// Clear mtime to force resync on next check.
err := e.db.Update(func(tx *sql.Tx) error {
_, err := tx.Exec(
"UPDATE sessions SET file_mtime = NULL"+
" WHERE id = ?",
sessionID,
)
return err
})
require.NoError(t, err, "clear mtime for %s", sessionID)
require.NoError(t, e.engine.SyncSingleSession(sessionID))
_, mtime, ok := e.db.GetSessionFileInfo(sessionID)
require.True(t, ok, "session file info not found")
assert.NotZero(t, mtime, "SyncSingleSession did not store mtime")
runSyncAndAssert(t, e.engine, sync.SyncStats{TotalSessions: 0 + 1, Synced: 0, Skipped: 1})
}
func fetchMessages(t *testing.T, database *db.DB, sessionID string) []db.Message {
t.Helper()
msgs, err := database.GetAllMessages(context.Background(), sessionID)
require.NoError(t, err, "GetAllMessages(%q)", sessionID)
return msgs
}
// assertMessageRoles verifies that a session's messages have
// the expected roles in order.
func assertMessageRoles(
t *testing.T, database *db.DB,
sessionID string, wantRoles ...string,
) {
t.Helper()
msgs := fetchMessages(t, database, sessionID)
require.Len(t, msgs, len(wantRoles))
for i, want := range wantRoles {
assert.Equal(t, want, msgs[i].Role, "msgs[%d].Role", i)
}
}
// assertMessageContent verifies that a session's messages
// have the expected content strings in ordinal order.
func assertMessageContent(
t *testing.T, database *db.DB,
sessionID string, wantContent ...string,
) {
t.Helper()
msgs := fetchMessages(t, database, sessionID)
require.Len(t, msgs, len(wantContent))
for i, want := range wantContent {
assert.Equal(t, want, msgs[i].Content, "msgs[%d].Content", i)
}
}
// assertToolCallCount verifies that the total number of
// tool_calls rows for a session matches the expected count.
func assertToolCallCount(
t *testing.T, database *db.DB,
sessionID string, want int,
) {
t.Helper()
var got int
err := database.Reader().QueryRow(
"SELECT COUNT(*) FROM tool_calls"+
" WHERE session_id = ?",
sessionID,
).Scan(&got)
require.NoError(t, err, "count tool_calls for %q", sessionID)
assert.Equal(t, want, got, "tool_calls count for %q", sessionID)
}
// updateSessionProject fetches the session, updates its
// Project field, and upserts it back. Reduces boilerplate
// for tests that need to override a single field.
func (e *testEnv) updateSessionProject(
t *testing.T, sessionID, project string,
) {
t.Helper()
sess, err := e.db.GetSessionFull(
context.Background(), sessionID,
)
require.NoError(t, err, "GetSessionFull")
require.NotNil(t, sess, "session %q not found", sessionID)
sess.Project = project
require.NoError(t, e.db.UpsertSession(*sess), "UpsertSession")
}
// openCodeTestDB manages an OpenCode SQLite database for tests.
type openCodeTestDB struct {
path string
db *sql.DB
}
type kiroSQLiteTestDB struct {
path string
db *sql.DB
}
var (
openCodeLikeSchemaOnce stdsync.Once
openCodeLikeSchemaBytes []byte
openCodeLikeSchemaErr error
kiroSQLiteSchemaOnce stdsync.Once
kiroSQLiteSchemaBytes []byte
kiroSQLiteSchemaErr error
antigravityCLISchemaOnce stdsync.Once
antigravityCLISchemaBytes []byte
antigravityCLISchemaErr error
piebaldSchemaOnce stdsync.Once
piebaldSchemaBytes []byte
piebaldSchemaErr error
shelleySchemaOnce stdsync.Once
shelleySchemaBytes []byte
shelleySchemaErr error
zedSchemaOnce stdsync.Once
zedSchemaBytes []byte
zedSchemaErr error
kiroSQLiteFixtureCache stdsync.Map
)
const openCodeLikeSchema = `
CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL
);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
title TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
data TEXT NOT NULL,
time_created INTEGER NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
message_id TEXT NOT NULL,
data TEXT NOT NULL,
time_created INTEGER NOT NULL
);
`
const kiroSQLiteSchema = `
CREATE TABLE conversations_v2 (
key TEXT NOT NULL,
conversation_id TEXT NOT NULL,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (key, conversation_id)
);
`
// createOpenCodeDB creates a minimal OpenCode SQLite database
// with the required schema (project, session, message, part
// tables). Returns a handle for inserting test data.
func createOpenCodeDB(t *testing.T, dir string) *openCodeTestDB {
t.Helper()
return createOpenCodeLikeDB(
t, filepath.Join(dir, "opencode.db"), "opencode",
)
}
func createKiloDB(t *testing.T, dir string) *openCodeTestDB {
t.Helper()
return createOpenCodeLikeDB(
t, filepath.Join(dir, "kilo.db"), "kilo",
)
}
func createOpenCodeLikeDB(
t *testing.T, path, label string,
) *openCodeTestDB {
t.Helper()
copySQLiteSchemaTemplate(
t, path, label, &openCodeLikeSchemaOnce,
&openCodeLikeSchemaBytes, &openCodeLikeSchemaErr,
openCodeLikeSchema,
)
d, err := sql.Open("sqlite3", path)
require.NoError(t, err, "opening %s test db", label)
t.Cleanup(func() { d.Close() })
return &openCodeTestDB{path: path, db: d}
}
func createKiroSQLiteDB(t *testing.T, dir string) *kiroSQLiteTestDB {
t.Helper()
path := filepath.Join(dir, "data.sqlite3")
copySQLiteSchemaTemplate(
t, path, "kiro sqlite", &kiroSQLiteSchemaOnce,
&kiroSQLiteSchemaBytes, &kiroSQLiteSchemaErr,
kiroSQLiteSchema,
)
d, err := sql.Open("sqlite3", path)
require.NoError(t, err, "opening kiro sqlite test db")
t.Cleanup(func() { d.Close() })
return &kiroSQLiteTestDB{path: path, db: d}
}
func copySQLiteSchemaTemplate(
t *testing.T,
path, label string,
once *stdsync.Once,
templateBytes *[]byte,
templateErr *error,
schema string,
) {
t.Helper()
bytes := sqliteSchemaTemplateBytes(
t, label, once, templateBytes, templateErr, schema,
)
require.NoError(t, os.WriteFile(path, bytes, 0o644),
"copy %s schema template", label)
}
func sqliteSchemaTemplateBytes(
t *testing.T,
label string,
once *stdsync.Once,
templateBytes *[]byte,
templateErr *error,
schema string,
) []byte {
t.Helper()
once.Do(func() {
dir, err := os.MkdirTemp("", "agentsview-"+label+"-schema-*")
if err != nil {
*templateErr = fmt.Errorf("create %s schema template dir: %w", label, err)
return
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "template.db")
d, err := sql.Open("sqlite3", path)
if err != nil {
*templateErr = fmt.Errorf("open %s schema template: %w", label, err)
return
}
if _, err = d.Exec(schema); err != nil {
_ = d.Close()
*templateErr = fmt.Errorf("create %s schema template: %w", label, err)
return
}
if err = d.Close(); err != nil {
*templateErr = fmt.Errorf("close %s schema template: %w", label, err)
return
}
*templateBytes, err = os.ReadFile(path)
if err != nil {
*templateErr = fmt.Errorf("read %s schema template: %w", label, err)
}
})
require.NoError(t, *templateErr, "prepare %s schema template", label)
require.NotEmpty(t, *templateBytes, "prepare %s schema template", label)
return *templateBytes
}
func readKiroSQLiteFixture(t *testing.T, name string) string {
t.Helper()
if v, ok := kiroSQLiteFixtureCache.Load(name); ok {
return v.(string)
}
path := filepath.Join(
"..", "parser", "testdata", "kiro_sqlite", name,
)
data, err := os.ReadFile(path)
require.NoError(t, err, "read kiro sqlite fixture %s", name)
value := string(data)
v, _ := kiroSQLiteFixtureCache.LoadOrStore(name, value)
return v.(string)
}
func (ks *kiroSQLiteTestDB) addSession(
t *testing.T, key, id, payload string,
createdAt, updatedAt int64,
) {
t.Helper()
_, err := ks.db.Exec(
`INSERT INTO conversations_v2
(key, conversation_id, value, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
key, id, payload, createdAt, updatedAt,
)
require.NoError(t, err, "insert kiro sqlite session")
}
func (ks *kiroSQLiteTestDB) updateSession(
t *testing.T, id, payload string, updatedAt int64,
) {
t.Helper()
_, err := ks.db.Exec(
`UPDATE conversations_v2
SET value = ?, updated_at = ?
WHERE conversation_id = ?`,
payload, updatedAt, id,
)
require.NoError(t, err, "update kiro sqlite session")
}
func writeLegacyKiroSession(
t *testing.T, dir, id, prompt string,
) {
t.Helper()
jsonlPath := filepath.Join(dir, id+".jsonl")
metaPath := filepath.Join(dir, id+".json")
require.NoError(t, os.WriteFile(
jsonlPath,
[]byte(`{"kind":"Prompt","data":{"content":[{"kind":"text","data":"`+
prompt+`"}]}}`+"\n"+
`{"kind":"AssistantMessage","data":{"content":[{"kind":"text","data":"legacy assistant"}]}}`+
"\n"),
0o644,
), "write legacy kiro jsonl")
require.NoError(t, os.WriteFile(
metaPath,
[]byte(`{"session_id":"`+id+`","cwd":"/home/user/code/legacy-kiro","created_at":"2026-05-17T09:00:00Z","updated_at":"2026-05-17T09:01:00Z"}`),
0o644,
), "write legacy kiro metadata")
}
func (oc *openCodeTestDB) mustExec(t *testing.T, msg, query string, args ...any) {
t.Helper()
_, err := oc.db.Exec(query, args...)
require.NoError(t, err, msg)
}
func (oc *openCodeTestDB) addProject(
t *testing.T, id, worktree string,
) {
t.Helper()
oc.mustExec(t, "insert project",
"INSERT INTO project (id, worktree) VALUES (?, ?)",
id, worktree,
)
}
func (oc *openCodeTestDB) addSession(
t *testing.T,
id, projectID string,
timeCreated, timeUpdated int64,
) {
t.Helper()
oc.mustExec(t, "insert session",
`INSERT INTO session
(id, project_id, time_created, time_updated)
VALUES (?, ?, ?, ?)`,
id, projectID, timeCreated, timeUpdated,
)
}
func (oc *openCodeTestDB) updateSessionTime(
t *testing.T, id string, timeUpdated int64,
) {
t.Helper()
oc.mustExec(t, "update session time",
"UPDATE session SET time_updated = ? WHERE id = ?",
timeUpdated, id,
)
}
func (oc *openCodeTestDB) addMessage(
t *testing.T,
id, sessionID, role string,
timeCreated int64,
) {
t.Helper()
data, err := json.Marshal(map[string]string{
"role": role,
})
require.NoError(t, err, "marshal message")
oc.mustExec(t, "insert message",
`INSERT INTO message
(id, session_id, data, time_created)
VALUES (?, ?, ?, ?)`,
id, sessionID, string(data), timeCreated,
)
}
func (oc *openCodeTestDB) updateMessageData(
t *testing.T, id string, data map[string]any,
) {
t.Helper()
raw, err := json.Marshal(data)
require.NoError(t, err, "marshal message update")
oc.mustExec(t, "update message data",
"UPDATE message SET data = ? WHERE id = ?",
string(raw), id,
)
}
func (oc *openCodeTestDB) addTextPart(
t *testing.T,
id, sessionID, messageID, content string,
timeCreated int64,
) {
t.Helper()
data, err := json.Marshal(map[string]string{
"type": "text",
"content": content,
})
require.NoError(t, err, "marshal text part")
oc.mustExec(t, "insert part",
`INSERT INTO part
(id, session_id, message_id, data, time_created)
VALUES (?, ?, ?, ?, ?)`,
id, sessionID, messageID, string(data), timeCreated,
)
}
func (oc *openCodeTestDB) addToolPart(
t *testing.T,
id, sessionID, messageID string,
toolName, callID string,
timeCreated int64,
) {
t.Helper()
data, err := json.Marshal(map[string]any{
"type": "tool",
"tool": toolName,
"callID": callID,
})
require.NoError(t, err, "marshal tool part")
oc.mustExec(t, "insert tool part",
`INSERT INTO part
(id, session_id, message_id, data, time_created)
VALUES (?, ?, ?, ?, ?)`,
id, sessionID, messageID, string(data), timeCreated,
)
}
func (oc *openCodeTestDB) deleteMessages(
t *testing.T, sessionID string,
) {
t.Helper()
oc.mustExec(t, "delete messages",
"DELETE FROM message WHERE session_id = ?",
sessionID,
)
}
func (oc *openCodeTestDB) deleteParts(
t *testing.T, sessionID string,
) {
t.Helper()
oc.mustExec(t, "delete parts",
"DELETE FROM part WHERE session_id = ?",
sessionID,
)
}
// replaceTextContent deletes all messages and parts for a
// session, then re-inserts them with new content but the same
// ordinal structure (user msg + assistant msg).
func (oc *openCodeTestDB) replaceTextContent(
t *testing.T,
sessionID string,
userContent, assistantContent string,
timeCreated int64,
) {
t.Helper()
oc.deleteMessages(t, sessionID)
oc.deleteParts(t, sessionID)
umID := fmt.Sprintf("%s-msg-user-v2", sessionID)
amID := fmt.Sprintf("%s-msg-asst-v2", sessionID)
oc.addMessage(t, umID, sessionID, "user", timeCreated)
oc.addMessage(
t, amID, sessionID, "assistant", timeCreated+1,
)
oc.addTextPart(
t, umID+"-p", sessionID, umID,
userContent, timeCreated,
)
oc.addTextPart(
t, amID+"-p", sessionID, amID,
assistantContent, timeCreated+1,
)
}
type openCodeStorageFixture struct {
root string
sessionSubdir string
}
func createOpenCodeStorageFixture(
t *testing.T, root string,
) *openCodeStorageFixture {
t.Helper()
return &openCodeStorageFixture{root: root, sessionSubdir: "session"}
}
// createMiMoCodeStorageFixture builds a fixture for MiMoCode, which
// stores session JSON under storage/session_diff instead of
// storage/session while sharing the message/part layout.
func createMiMoCodeStorageFixture(
t *testing.T, root string,
) *openCodeStorageFixture {
t.Helper()
return &openCodeStorageFixture{root: root, sessionSubdir: "session_diff"}
}
func (oc *openCodeStorageFixture) writeJSON(
t *testing.T, path string, data any,
) string {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "mkdir %s", filepath.Dir(path))
raw, err := json.Marshal(data)
require.NoError(t, err, "marshal %s", path)
require.NoError(t, os.WriteFile(path, raw, 0o644), "write %s", path)
return path
}
func (oc *openCodeStorageFixture) addSession(
t *testing.T,
projectID, sessionID, directory, title string,
timeCreated, timeUpdated int64,
) string {
t.Helper()
return oc.writeJSON(t, filepath.Join(
oc.root, "storage", oc.sessionSubdir, projectID,
sessionID+".json",
), map[string]any{
"id": sessionID,
"projectID": projectID,
"directory": directory,
"title": title,
"time": map[string]any{
"created": timeCreated,
"updated": timeUpdated,
},
})
}
func (oc *openCodeStorageFixture) addMessage(
t *testing.T,
sessionID, messageID, role string,
timeCreated int64,
extra map[string]any,
) string {
t.Helper()
data := map[string]any{
"id": messageID,
"sessionID": sessionID,
"role": role,
"time": map[string]any{
"created": timeCreated,
},
}
maps.Copy(data, extra)
return oc.writeJSON(t, filepath.Join(
oc.root, "storage", "message", sessionID,
messageID+".json",
), data)
}
func (oc *openCodeStorageFixture) addTextPart(
t *testing.T,
sessionID, messageID, partID, text string,
timeCreated int64,
) string {
t.Helper()
return oc.writeJSON(t, filepath.Join(
oc.root, "storage", "part", messageID,
partID+".json",
), map[string]any{
"id": partID,
"sessionID": sessionID,
"messageID": messageID,
"type": "text",
"text": text,
"time": map[string]any{
"created": timeCreated,
},
})
}