Files
openclaw/apps/macos/Tests/OpenClawIPCTests/MacNodeClaudeSessionCatalogTests.swift

535 lines
23 KiB
Swift

import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized)
struct MacNodeClaudeSessionCatalogTests {
private final class EnumerationCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
func increment() {
self.lock.withLock { self.count += 1 }
}
func value() -> Int {
self.lock.withLock { self.count }
}
}
private final class Fixture {
let home: URL
init() throws {
self.home = FileManager.default.temporaryDirectory
.appendingPathComponent("openclaw-claude-home-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: self.home, withIntermediateDirectories: true)
}
deinit {
MacNodeClaudeSessionCatalog.setCatalogEnumerationObserverForTesting(nil)
try? FileManager.default.removeItem(at: self.home)
}
var projects: URL {
self.home.appendingPathComponent(".claude/projects", isDirectory: true)
}
func project(_ name: String = "-workspace") throws -> URL {
let url = self.projects.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
func file(_ sessionId: String, in project: URL) -> URL {
project.appendingPathComponent("\(sessionId).jsonl")
}
func indexEntry(
_ sessionId: String,
in project: URL,
summary: String? = nil,
modified: String? = nil,
projectPath: String? = nil,
isSidechain: Bool = false) -> [String: Any]
{
var entry: [String: Any] = [
"sessionId": sessionId,
"fullPath": self.file(sessionId, in: project).path,
"isSidechain": isSidechain,
]
entry["summary"] = summary
entry["modified"] = modified
entry["projectPath"] = projectPath
return entry
}
func writeIndex(_ entries: [[String: Any]], in project: URL) throws {
try self.writeJSON(
["version": 1, "entries": entries],
to: project.appendingPathComponent("sessions-index.json"))
}
func desktopMetadata(_ name: String) -> URL {
self.home.appendingPathComponent(
"Library/Application Support/Claude/claude-code-sessions/account/workspace/\(name).json")
}
func writeJSON(_ value: Any, to url: URL) throws {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]).write(to: url)
}
func message(
_ sessionId: String,
_ text: String,
role: String = "user",
index: Int = 1,
entrypoint: String? = nil,
cwd: String? = nil,
version: String? = nil,
isSidechain: Bool = false) -> [String: Any]
{
var row: [String: Any] = [
"type": role,
"sessionId": sessionId,
"uuid": "\(sessionId)-\(index)",
"timestamp": "2026-07-0\(index)T00:00:00.000Z",
"isSidechain": isSidechain,
"message": [
"role": role,
"content": [["type": "text", "text": text]],
"model": "claude-opus-4-8",
],
]
row["entrypoint"] = entrypoint
row["cwd"] = cwd
row["version"] = version
return row
}
func writeTranscript(_ rows: [[String: Any]], to url: URL) throws {
let lines = try rows.map { row in
try #require(String(
data: JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]),
encoding: .utf8))
}
try (lines.joined(separator: "\n") + "\n").write(
to: url,
atomically: true,
encoding: .utf8)
}
func params(_ value: [String: Any]) throws -> String? {
try String(data: JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), encoding: .utf8)
}
func decode(_ json: String) -> [String: Any]? {
try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]
}
func list(paramsJSON: String? = nil) throws -> [String: Any]? {
try self.decode(MacNodeClaudeSessionCatalog.list(paramsJSON: paramsJSON, homeURL: self.home))
}
func read(_ paramsJSON: String) throws -> [String: Any]? {
try self.decode(self.readJSON(paramsJSON))
}
func readJSON(_ paramsJSON: String) throws -> String {
try MacNodeClaudeSessionCatalog.read(paramsJSON: paramsJSON, homeURL: self.home)
}
func observeEnumerations() -> EnumerationCounter {
let counter = EnumerationCounter()
let projectsPath = self.projects.standardizedFileURL.path
MacNodeClaudeSessionCatalog.setCatalogEnumerationObserverForTesting { rootPath in
if rootPath == projectsPath { counter.increment() }
}
return counter
}
}
@Test func `merges Desktop metadata over CLI indexes and filters archived sessions`() throws {
let fixture = try Fixture()
let desktopId = "desktop-session"
let cliId = "cli-session"
let archivedId = "archived-session"
let project = try fixture.project()
try fixture.writeIndex([
fixture.indexEntry(
cliId,
in: project,
summary: "CLI title",
modified: "2026-07-01T00:00:00.000Z",
projectPath: "/work/cli"),
fixture.indexEntry(
desktopId,
in: project,
summary: "Index title",
modified: "2026-07-02T00:00:00.000Z"),
fixture.indexEntry(
archivedId,
in: project,
summary: "Archived",
modified: "2026-07-03T00:00:00.000Z"),
], in: project)
for sessionId in [desktopId, cliId, archivedId] {
try fixture.writeTranscript(
[fixture.message(sessionId, sessionId)],
to: fixture.file(sessionId, in: project))
}
try fixture.writeJSON(
[
"sessionId": "local-active",
"cliSessionId": desktopId,
"title": "Desktop title",
"cwd": "/desktop/cwd",
"lastActivityAt": 1_783_137_600_000 as Int64,
"isArchived": false,
],
to: fixture.desktopMetadata("local_active"))
try fixture.writeJSON(
[
"sessionId": "local-archived",
"cliSessionId": archivedId,
"isArchived": true,
],
to: fixture.desktopMetadata("local_archived"))
let first = try #require(try fixture.list(paramsJSON: #"{"limit":1}"#))
let firstSessions = try #require(first["sessions"] as? [[String: Any]])
#expect(firstSessions.count == 1)
#expect(firstSessions[0]["threadId"] as? String == desktopId)
#expect(firstSessions[0]["name"] as? String == "Desktop title")
#expect(firstSessions[0]["source"] as? String == "claude-desktop")
#expect(firstSessions[0]["cwd"] as? String == "/desktop/cwd")
let cursor = try #require(first["nextCursor"] as? String)
let secondParams = try fixture.params(["limit": 1, "cursor": cursor])
let second = try #require(try fixture.list(paramsJSON: secondParams))
let secondSessions = try #require(second["sessions"] as? [[String: Any]])
#expect(secondSessions.map { $0["threadId"] as? String } == [cliId])
#expect(secondSessions[0]["updatedAt"] as? Int64 == 1_782_864_000_000)
#expect(second["nextCursor"] == nil)
#expect(throws: MacNodeClaudeSessionCatalog.CatalogError.self) {
try fixture.read(#"{"threadId":"archived-session","limit":1}"#)
}
}
@Test func `discovers CLI fallback transcripts and rejects sidechains foreign entrypoints and escapes`() throws {
let fixture = try Fixture()
let project = try fixture.project()
let sidechainId = "sidechain-session"
let cliSidechainId = "cli-sidechain-session"
let discoveredSidechainId = "discovered-sidechain"
let foreignEntrypointId = "foreign-entrypoint-session"
let unindexedId = "unindexed-session"
let cliId = "cli-session"
let sdkCLIId = "sdk-cli-session"
let escapedId = "escaped-session"
let escapedURL = fixture.file(escapedId, in: project)
let outsideURL = fixture.home.appendingPathComponent("outside.jsonl")
try fixture.writeIndex([
fixture.indexEntry(sidechainId, in: project, isSidechain: true),
fixture.indexEntry(escapedId, in: project),
], in: project)
try fixture.writeTranscript(
[fixture.message(sidechainId, "sidechain")],
to: fixture.file(sidechainId, in: project))
try fixture.writeTranscript(
[fixture.message(unindexedId, "unindexed")],
to: fixture.file(unindexedId, in: project))
for (id, text, entrypoint, cwd, version) in [
(cliId, "Interactive CLI prompt", "cli", "/work/cli", "2.1.216"),
(sdkCLIId, "Headless CLI prompt", "sdk-cli", "/work/sdk", "2.1.204"),
] {
try fixture.writeTranscript(
[fixture.message(
id,
text,
entrypoint: entrypoint,
cwd: cwd,
version: version)],
to: fixture.file(id, in: project))
}
for (id, text, entrypoint, isSidechain) in [
(cliSidechainId, "interactive sidechain", "cli", true),
(discoveredSidechainId, "headless sidechain", "sdk-cli", true),
(foreignEntrypointId, "SDK session", "sdk-ts", false),
] {
try fixture.writeTranscript(
[fixture.message(
id,
text,
entrypoint: entrypoint,
isSidechain: isSidechain)],
to: fixture.file(id, in: project))
}
try fixture.writeTranscript(
[fixture.message(escapedId, "outside")],
to: outsideURL)
try FileManager.default.createSymbolicLink(at: escapedURL, withDestinationURL: outsideURL)
for (name, sessionId, title) in [
("local_sidechain", sidechainId, "Desktop sidechain"),
("local_cli_sidechain", cliSidechainId, "Interactive Desktop sidechain"),
("local_discovered_sidechain", discoveredSidechainId, "Headless Desktop sidechain"),
] {
try fixture.writeJSON(
["cliSessionId": sessionId, "title": title, "isArchived": false],
to: fixture.desktopMetadata(name))
}
let list = try #require(try fixture.list())
let sessions = try #require(list["sessions"] as? [[String: Any]])
#expect(sessions.compactMap { $0["threadId"] as? String }.sorted() == [cliId, sdkCLIId])
#expect(
sessions.first(where: { $0["threadId"] as? String == cliId })?["name"] as? String ==
"Interactive CLI prompt")
for (threadId, expectedText) in [
(cliId, "Interactive CLI prompt"),
(sdkCLIId, "Headless CLI prompt"),
] {
let read = try #require(try fixture.read(#"{"threadId":"\#(threadId)","limit":1}"#))
let items = try #require(read["items"] as? [[String: Any]])
#expect(items.first?["text"] as? String == expectedText)
}
for threadId in [
sidechainId,
cliSidechainId,
discoveredSidechainId,
foreignEntrypointId,
unindexedId,
escapedId,
] {
#expect(throws: MacNodeClaudeSessionCatalog.CatalogError.self) {
try fixture.read(#"{"threadId":"\#(threadId)","limit":1}"#)
}
}
}
@Test func `cached metadata honors access revocation and refreshes replaced transcripts`() throws {
let fixture = try Fixture()
let project = try fixture.project()
let sessionId = "cached-session"
let transcript = fixture.file(sessionId, in: project)
try fixture.writeTranscript(
[fixture.message(
sessionId,
"Alpha",
entrypoint: "sdk-cli")],
to: transcript)
let first = try #require(try fixture.list())
#expect((first["sessions"] as? [[String: Any]])?.first?["name"] as? String == "Alpha")
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: transcript.path)
defer {
try? FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: transcript.path)
}
let cached = try #require(try fixture.list())
#expect((cached["sessions"] as? [[String: Any]])?.isEmpty == true)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: transcript.path)
let restored = try #require(try fixture.list())
#expect((restored["sessions"] as? [[String: Any]])?.first?["name"] as? String == "Alpha")
try fixture.writeTranscript(
[fixture.message(
sessionId,
"Bravo",
index: 2,
entrypoint: "sdk-cli")],
to: transcript)
let refreshed = try #require(try fixture.list())
#expect((refreshed["sessions"] as? [[String: Any]])?.first?["name"] as? String == "Bravo")
}
@Test func `reuses catalog discovery only across transcript read pages`() throws {
let fixture = try Fixture()
let project = try fixture.project()
let sessionId = "transcript-session"
let oldUser = String(repeating: "old user ", count: 20000)
let transcript = fixture.file(sessionId, in: project)
try fixture.writeIndex([
fixture.indexEntry(
sessionId,
in: project,
summary: "Transcript",
modified: "2026-07-04T00:00:00.000Z"),
], in: project)
try fixture.writeTranscript([
["type": "queue-operation", "sessionId": sessionId],
fixture.message(sessionId, oldUser),
fixture.message(sessionId, "old assistant", role: "assistant", index: 2),
fixture.message(sessionId, "new user", index: 3),
fixture.message(sessionId, "new assistant", role: "assistant", index: 4),
], to: transcript)
let enumerations = fixture.observeEnumerations()
let latest = try #require(try fixture.read(#"{"threadId":"transcript-session","limit":2}"#))
let latestItems = try #require(latest["items"] as? [[String: Any]])
#expect(latestItems.map { $0["text"] as? String } == ["new assistant", "new user"])
let cursor = try #require(latest["nextCursor"] as? String)
#expect(enumerations.value() == 1)
let olderParams = try #require(try fixture.params([
"threadId": sessionId,
"limit": 2,
"cursor": cursor,
]))
let older = try #require(try fixture.read(olderParams))
let olderItems = try #require(older["items"] as? [[String: Any]])
#expect(olderItems.map { $0["text"] as? String } == ["old assistant", oldUser])
#expect(older["nextCursor"] == nil)
#expect(enumerations.value() == 1)
}
@Test func `missing pagination lease falls back to current discovery`() throws {
let fixture = try Fixture()
let project = try fixture.project()
let sessionId = "lease-session"
let transcript = fixture.file(sessionId, in: project)
try fixture.writeIndex([fixture.indexEntry(sessionId, in: project)], in: project)
try fixture.writeTranscript([
fixture.message(sessionId, "old"),
fixture.message(sessionId, "new", role: "assistant", index: 2),
], to: transcript)
let enumerations = fixture.observeEnumerations()
let first = try #require(try fixture.read(#"{"threadId":"lease-session","limit":1}"#))
var encoded = try #require(first["nextCursor"] as? String)
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4)
let cursorData = try #require(Data(base64Encoded: encoded))
var cursorValue = try #require(
JSONSerialization.jsonObject(with: cursorData) as? [String: Any])
cursorValue["lease"] = UUID().uuidString
let missingLeaseCursor = try JSONSerialization.data(
withJSONObject: cursorValue,
options: [.sortedKeys]).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let secondParams = try #require(try fixture.params([
"threadId": sessionId,
"limit": 1,
"cursor": missingLeaseCursor,
]))
let second = try #require(try fixture.read(secondParams))
#expect((second["items"] as? [[String: Any]])?.first?["text"] as? String == "old")
#expect(enumerations.value() == 2)
}
@Test func `fresh reads recheck catalog eligibility after a pagination lease`() throws {
let fixture = try Fixture()
let project = try fixture.project()
let sessionId = "eligibility-session"
try fixture.writeTranscript([
fixture.message(sessionId, "old"),
fixture.message(sessionId, "new", role: "assistant", index: 2),
], to: fixture.file(sessionId, in: project))
try fixture.writeIndex([fixture.indexEntry(sessionId, in: project)], in: project)
let first = try #require(try fixture.read(#"{"threadId":"eligibility-session","limit":1}"#))
#expect(first["nextCursor"] is String)
try fixture.writeIndex([
fixture.indexEntry(sessionId, in: project, isSidechain: true),
], in: project)
#expect(throws: MacNodeClaudeSessionCatalog.CatalogError.self) {
try fixture.read(#"{"threadId":"eligibility-session","limit":1}"#)
}
}
@Test func `fresh reads discover a moved session file`() throws {
let fixture = try Fixture()
let firstProject = try fixture.project("-first")
let sessionId = "moving-session"
let firstTranscript = fixture.file(sessionId, in: firstProject)
try fixture.writeTranscript([
fixture.message(sessionId, "first location"),
fixture.message(sessionId, "latest", role: "assistant", index: 2),
], to: firstTranscript)
try fixture.writeIndex([
fixture.indexEntry(sessionId, in: firstProject, summary: "Moving"),
], in: firstProject)
let enumerations = fixture.observeEnumerations()
let firstPayload = try #require(try fixture.read(#"{"threadId":"moving-session","limit":1}"#))
#expect((firstPayload["items"] as? [[String: Any]])?.first?["text"] as? String == "latest")
#expect(enumerations.value() == 1)
let secondProject = try fixture.project("-second")
let secondTranscript = fixture.file(sessionId, in: secondProject)
try FileManager.default.moveItem(at: firstTranscript, to: secondTranscript)
try fixture.writeIndex([], in: firstProject)
try fixture.writeIndex([
fixture.indexEntry(sessionId, in: secondProject, summary: "Moving"),
], in: secondProject)
let refreshedPayload = try #require(try fixture.read(#"{"threadId":"moving-session","limit":1}"#))
#expect((refreshedPayload["items"] as? [[String: Any]])?.first?["text"] as? String == "latest")
#expect(enumerations.value() == 2)
_ = try fixture.read(#"{"threadId":"moving-session","limit":1}"#)
#expect(enumerations.value() == 3)
}
@Test func `bounds oversized transcript items by encoded response size`() throws {
let fixture = try Fixture()
let project = try fixture.project()
let sessionId = "oversized-session"
let transcript = fixture.file(sessionId, in: project)
try fixture.writeIndex([
fixture.indexEntry(
sessionId,
in: project,
summary: "Oversized",
modified: "2026-07-04T00:00:00.000Z"),
], in: project)
let oneGrapheme = "a" + String(repeating: "\u{0301}", count: 2_200_000)
try fixture.writeTranscript(
[fixture.message(sessionId, oneGrapheme)],
to: transcript)
let response = try fixture.readJSON(#"{"threadId":"oversized-session","limit":1}"#)
#expect(response.utf8.count <= 20 * 1024 * 1024)
let decoded = try #require(fixture.decode(response))
let items = try #require(decoded["items"] as? [[String: Any]])
#expect(items.first?["truncated"] as? Bool == true)
let itemData = try JSONSerialization.data(withJSONObject: #require(items.first))
#expect(itemData.count <= 4 * 1024 * 1024)
}
@Test func `advertises only when Anthropic is enabled and Claude projects exist`() throws {
let fixture = try Fixture()
let root: [String: Any] = ["plugins": ["entries": ["anthropic": ["enabled": true]]]]
#expect(!MacNodeClaudeSessionCatalog.shouldAdvertise(root: root, homeURL: fixture.home))
try FileManager.default.createDirectory(
at: fixture.projects,
withIntermediateDirectories: true)
#expect(MacNodeClaudeSessionCatalog.shouldAdvertise(root: [:], homeURL: fixture.home))
#expect(MacNodeClaudeSessionCatalog.shouldAdvertise(root: root, homeURL: fixture.home))
let disabled: [String: Any] = ["plugins": ["entries": ["anthropic": ["enabled": false]]]]
#expect(!MacNodeClaudeSessionCatalog.shouldAdvertise(root: disabled, homeURL: fixture.home))
let denied: [String: Any] = ["plugins": ["deny": ["anthropic"]]]
#expect(!MacNodeClaudeSessionCatalog.shouldAdvertise(root: denied, homeURL: fixture.home))
let omittedByAllowlist: [String: Any] = ["plugins": ["allow": ["codex"]]]
#expect(!MacNodeClaudeSessionCatalog.shouldAdvertise(root: omittedByAllowlist, homeURL: fixture.home))
let entries: [String: Any] = ["anthropic": ["enabled": true], "Anthropic": ["enabled": false]]
let ambiguous: [String: Any] = ["plugins": ["entries": entries]]
#expect(!MacNodeClaudeSessionCatalog.shouldAdvertise(root: ambiguous, homeURL: fixture.home))
}
}