Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Modules/Sources/WordPressKit/Activity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ public struct ActivityActor {
public let wpcomUserID: String
public let avatarURL: String
public let role: String
public let isMCPAgent: Bool
public let mcpClient: String?

init(dictionary: [String: Any]) {
displayName = dictionary["name"] as? String ?? ""
Expand All @@ -132,6 +134,8 @@ public struct ActivityActor {
avatarURL = ""
}
role = dictionary["role"] as? String ?? ""
isMCPAgent = dictionary["is_mcp_agent"] as? Bool ?? false
mcpClient = dictionary["mcp_client"] as? String
}

public lazy var isJetpack: Bool = {
Expand Down
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* [*] [internal] Fix retain cycle in Reader Article screen [#25364]
* [*] Fix incorrect default Post Format in new posts [#25369]
* [*] Posts and Pages are available to XMLRPC-disable sites [#25382]
* [*] Activity Log: Show when actions were performed by an MCP agent [#25397]

26.7
-----
Expand Down
3 changes: 2 additions & 1 deletion Scripts/download-gutenberg-xcframeworks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ if [[ -z "${FRAMEWORKS_DIR}" || "${FRAMEWORKS_DIR}" == "/" ]]; then
echo "Error: invalid frameworks directory: '${FRAMEWORKS_DIR}'" >&2
exit 1
fi
cp -a "${CACHE_DIR}" "${FRAMEWORKS_DIR}"
mkdir -p "${FRAMEWORKS_DIR}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the change related to the PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only insomuch as I hit it while testing and didn't think it was worth its own PR.

cp -a "${CACHE_DIR}/." "${FRAMEWORKS_DIR}/"

echo "Gutenberg ${VERSION} setup complete."
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
@testable import WordPress
@testable import WordPressKit
import Testing

struct ActivityStringFormattingTests {

// MARK: - actorName

@Test func actorNameReturnsDisplayName() {
let actor = ActivityActor(dictionary: ["name": "Alice", "type": "Person", "role": "administrator"])
#expect(ActivityStringFormatting.actorName(for: actor) == "Alice")
}

@Test func actorNameReturnsUnknownUserWhenEmpty() {
let actor = ActivityActor(dictionary: ["name": "", "type": "Person", "role": "editor"])
#expect(ActivityStringFormatting.actorName(for: actor) == Activity.Strings.unknownUser)
}

@Test func actorNameReturnsUnknownUserWhenMissing() {
let actor = ActivityActor(dictionary: ["type": "Person", "role": "editor"])
#expect(ActivityStringFormatting.actorName(for: actor) == Activity.Strings.unknownUser)
}

// MARK: - actorRole

@Test func actorRoleReturnsCapitalizedRole() {
let actor = ActivityActor(dictionary: ["name": "Alice", "type": "Person", "role": "administrator"])
#expect(ActivityStringFormatting.actorRole(for: actor) == "Administrator")
}

@Test func actorRoleFallsBackToTypeWhenRoleEmpty() {
let actor = ActivityActor(dictionary: ["name": "Jetpack", "type": "Application", "role": ""])
#expect(ActivityStringFormatting.actorRole(for: actor) == "Application")
}

@Test func actorRoleFallsBackToTypeWhenRoleMissing() {
let actor = ActivityActor(dictionary: ["name": "Jetpack", "type": "Application"])
#expect(ActivityStringFormatting.actorRole(for: actor) == "Application")
}

// MARK: - botName

@Test func botNameForMCPAgent() {
let actor = ActivityActor(dictionary: [
"name": "bot-user",
"type": "Person",
"role": "administrator",
"is_mcp_agent": true,
"mcp_client": "Claude"
])
#expect(ActivityStringFormatting.botName(for: actor) == "via Claude")
}

@Test func botNameForNonMCPActor() {
let actor = ActivityActor(dictionary: ["name": "Alice", "type": "Person", "role": "editor"])
#expect(ActivityStringFormatting.botName(for: actor) == nil)
}

@Test func botNameForMCPAgentWithEmptyClient() {
let actor = ActivityActor(dictionary: [
"name": "bot-user",
"type": "Person",
"role": "administrator",
"is_mcp_agent": true,
"mcp_client": ""
])
#expect(ActivityStringFormatting.botName(for: actor) == nil)
}

@Test func botNameForMCPAgentWithNoClient() {
let actor = ActivityActor(dictionary: [
"name": "bot-user",
"type": "Person",
"role": "administrator",
"is_mcp_agent": true
])
#expect(ActivityStringFormatting.botName(for: actor) == nil)
}
}
173 changes: 163 additions & 10 deletions Tests/WordPressKitTests/WordPressKitTests/Tests/ActivityTests.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,173 @@
@testable import WordPressKit
import XCTest
import Testing

class ActivityTests: XCTestCase {
struct ActivityTests {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to see more XCTest rework.


func testActivityDecoding() throws {
let data = try XCTUnwrap(activityLogComment.data(using: .utf8))
// This is part of the test in itself.
// If Activity is not configured as expected to decode the JSON input, JSONDecode will throw.
@Test func activityDecoding() throws {
let data = try #require(activityLogComment.data(using: .utf8))
let activity = try JSONDecoder().decode(Activity.self, from: data)
// Verify custom keys
XCTAssertEqual(activity.activityID, "AWRNRTAUjEqjFGbx8DZj")
XCTAssertFalse(activity.isRewindable)
XCTAssertEqual(activity.rewindID, "1530304735.2771")
#expect(activity.activityID == "AWRNRTAUjEqjFGbx8DZj")
#expect(activity.isRewindable == false)
#expect(activity.rewindID == "1530304735.2771")
}

@Test func actorWithMCPAgentFields() throws {
let data = try #require(activityLogMCPAgent.data(using: .utf8))
let activity = try JSONDecoder().decode(Activity.self, from: data)
let actor = try #require(activity.actor)
#expect(actor.isMCPAgent == true)
#expect(actor.mcpClient == "Claude")
#expect(actor.displayName == "bot-user")
#expect(actor.role == "administrator")
}

@Test func actorWithoutMCPAgentFields() throws {
let data = try #require(activityLogComment.data(using: .utf8))
let activity = try JSONDecoder().decode(Activity.self, from: data)
let actor = try #require(activity.actor)
#expect(actor.isMCPAgent == false)
#expect(actor.mcpClient == nil)
}

@Test func actorWithExplicitlyFalseMCPAgent() throws {
let data = try #require(activityLogExplicitlyNotMCPAgent.data(using: .utf8))
let activity = try JSONDecoder().decode(Activity.self, from: data)
let actor = try #require(activity.actor)
#expect(actor.isMCPAgent == false)
#expect(actor.mcpClient == nil)
}

@Test func actorWithMCPAgentButNoClient() throws {
let data = try #require(activityLogMCPAgentNoClient.data(using: .utf8))
let activity = try JSONDecoder().decode(Activity.self, from: data)
let actor = try #require(activity.actor)
#expect(actor.isMCPAgent == true)
#expect(actor.mcpClient == nil)
}
}

private let activityLogMCPAgent: String = """
{
"summary": "Post published",
"content": {
"text": "Post published by MCP agent"
},
"name": "post__published",
"actor": {
"type": "Person",
"name": "bot-user",
"external_user_id": 0,
"wpcom_user_id": 100000001,
"icon": {
"type": "Image",
"url": "https://secure.gravatar.com/avatar/example?s=96&d=identicon&r=g",
"width": 96,
"height": 96
},
"role": "administrator",
"is_mcp_agent": true,
"mcp_client": "Claude"
},
"type": "Create",
"published": "2026-03-17T10:00:00.000+00:00",
"generator": {
"jetpack_version": 0,
"blog_id": 137726971
},
"is_rewindable": false,
"rewind_id": "1710000000.0001",
"gridicon": "posts",
"status": "success",
"activity_id": "test-mcp-agent-activity",
"object": {
"type": "Post",
"object_id": 100
},
"is_discarded": false
}
"""

private let activityLogExplicitlyNotMCPAgent: String = """

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) It might be worth adding them in Resources to reduce the amount of work for the compiler and let you see these files with a JSON viewer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I didn't love this either but I figured I'd leave it to match what's already there 🤷

{
"summary": "Post updated",
"content": {
"text": "Post updated by a human"
},
"name": "post__updated",
"actor": {
"type": "Person",
"name": "human-user",
"external_user_id": 0,
"wpcom_user_id": 100000002,
"icon": {
"type": "Image",
"url": "https://secure.gravatar.com/avatar/example2?s=96&d=identicon&r=g",
"width": 96,
"height": 96
},
"role": "editor",
"is_mcp_agent": false,
"mcp_client": null
},
"type": "Update",
"published": "2026-03-17T11:00:00.000+00:00",
"generator": {
"jetpack_version": 0,
"blog_id": 137726971
},
"is_rewindable": false,
"rewind_id": "1710000001.0001",
"gridicon": "posts",
"status": "success",
"activity_id": "test-not-mcp-agent",
"object": {
"type": "Post",
"object_id": 101
},
"is_discarded": false
}
"""

private let activityLogMCPAgentNoClient: String = """
{
"summary": "Post drafted",
"content": {
"text": "Post drafted by MCP agent without client"
},
"name": "post__drafted",
"actor": {
"type": "Person",
"name": "agent-user",
"external_user_id": 0,
"wpcom_user_id": 100000003,
"icon": {
"type": "Image",
"url": "https://secure.gravatar.com/avatar/example3?s=96&d=identicon&r=g",
"width": 96,
"height": 96
},
"role": "administrator",
"is_mcp_agent": true
},
"type": "Create",
"published": "2026-03-17T12:00:00.000+00:00",
"generator": {
"jetpack_version": 0,
"blog_id": 137726971
},
"is_rewindable": false,
"rewind_id": "1710000002.0001",
"gridicon": "posts",
"status": "success",
"activity_id": "test-mcp-agent-no-client",
"object": {
"type": "Post",
"object_id": 102
},
"is_discarded": false
}
"""

// See https://github.com/wordpress-mobile/WordPress-iOS/blob/16adc688f718136ea57c45d5d26c5c13de9d2b9f/WordPress/WordPressTest/Test%20Data/activity-log-comment.json
private let activityLogComment: String = """
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation
import WordPressData

struct ActivityStringFormatting {
private static let agentString = NSLocalizedString(
"activityDetail.section.agent",
value: "via %1$@",
comment: "Shows the MCP client used for an activity. %1$@ is the MCP client name (e.g. Claude)."
)

static func actorName(for actor: ActivityActor) -> String {
actor.displayName.isEmpty ? Activity.Strings.unknownUser : actor.displayName
}

static func actorRole(for actor: ActivityActor) -> String {
actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized
}

static func botName(for actor: ActivityActor) -> String? {
guard actor.isMCPAgent, let mcpClient = actor.mcpClient, !mcpClient.isEmpty else {
return nil
}

return String(format: Self.agentString, mcpClient)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,46 @@
return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!)
}

static var mockMCPAgentActivity: Activity {
let json = """
{
"summary": "Post published",
"content": {
"text": "Published blog post: Getting Started with MCP"
},
"name": "post__published",
"actor": {
"type": "Person",
"name": "bot-user",
"external_user_id": 0,
"wpcom_user_id": 100000001,
"icon": {
"type": "Image",
"url": "",
"width": 96,
"height": 96
},
"role": "administrator",
"is_mcp_agent": true,
"mcp_client": "Claude"
},
"type": "Create",
"published": "2026-03-17T10:00:00.000+00:00",
"generator": {
"jetpack_version": 0,
"blog_id": 137726971
},
"is_rewindable": false,
"rewind_id": "1710000000.0001",
"gridicon": "posts",
"status": "success",
"activity_id": "mock-mcp-agent-activity",
"is_discarded": false
}
"""
return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!)

Check failure on line 102 in WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "try" or "try?" here instead; "try!" disables error propagation.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZz-kIVIUXD6ZtoBpu30&open=AZz-kIVIUXD6ZtoBpu30&pullRequest=25397
}

static var mockLoginActivity: Activity {
let json = """
{
Expand Down
Loading