Files
openclaw/apps/ios/Tests/AgentAutomationModelsTests.swift
Peter Steinberger 334e7f3f21 feat(apps): add native mobile Automations parity (#106355)
* fix(apps): address native Skills review feedback

* feat(apps): add native Automations parity

* fix(ios): preserve automation editor selection

* fix(android): page automation discovery

* chore(apps): refresh native source inventory

* fix(ios): refresh invalid automation diagnostics

* chore(apps): sync native localization

* fix(cron): stabilize paginated snapshots

* refactor(cron): split pagination helpers

* fix(apps): address final native review feedback

* chore(apps): refresh native source inventory

* fix(ios): dedupe automation list actions

* chore: keep release note in PR context

* fix(ci): repair current main architecture gates

* fix(ios): default new skill requirement fields

* fix(ios): retain queued automation reservations
2026-07-13 15:35:32 -07:00

250 lines
11 KiB
Swift

import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClaw
struct AgentAutomationModelsTests {
@Test func `draft decodes editable gateway fields`() throws {
let draft = try #require(AgentAutomationDraft(job: Self.job()))
#expect(draft.name == "Release briefing")
#expect(draft.sessionTarget == "isolated")
#expect(draft.schedule == .every(everyMs: "86400000", anchorMs: "1783468800000"))
#expect(draft.payload == .agentTurn(
message: "Summarize release readiness.",
model: "openai/gpt-5.2",
thinking: ""))
}
@Test func `update includes exact revision and only normalized changes`() throws {
let job = Self.job()
var draft = try #require(AgentAutomationDraft(job: job))
draft.name = " Release briefing v2 "
draft.payload = .agentTurn(
message: "Summarize release readiness.",
model: "",
thinking: "")
let json = try buildAgentAutomationUpdateParams(job: job, draft: draft)
let root = try #require(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
let patch = try #require(root["patch"] as? [String: Any])
let payload = try #require(patch["payload"] as? [String: Any])
#expect(root["id"] as? String == job.id)
#expect(root["expectedConfigRevision"] as? String == "sha256:test-revision")
#expect(patch["name"] as? String == "Release briefing v2")
#expect(payload["kind"] as? String == "agentTurn")
#expect(payload["model"] is NSNull)
#expect(payload["message"] == nil)
}
@Test func `semantic command formatting is not a change`() throws {
let job = Self.job(
payload: AnyCodable([
"kind": AnyCodable("command"),
"argv": AnyCodable([AnyCodable("openclaw"), AnyCodable("status")]),
"cwd": AnyCodable("/tmp"),
]))
var draft = try #require(AgentAutomationDraft(job: job))
draft.payload = .command(argvJSON: "[ \"openclaw\", \"status\" ]", cwd: "/tmp")
#expect(throws: AgentAutomationEditError.self) {
_ = try buildAgentAutomationUpdateParams(job: job, draft: draft)
}
}
@Test func `enable update is revision safe`() throws {
let root = try #require(
try JSONSerialization.jsonObject(
with: Data(buildAgentAutomationEnabledParams(job: Self.job(), enabled: false).utf8)) as? [String: Any])
let patch = try #require(root["patch"] as? [String: Any])
#expect(root["expectedConfigRevision"] as? String == "sha256:test-revision")
#expect(patch["enabled"] as? Bool == false)
}
@Test func `tracked run outcome preserves failures and skips`() {
#expect(agentAutomationRunOutcome(status: "ok", error: nil) == .success)
#expect(agentAutomationRunOutcome(status: "skipped", error: nil) == .skipped)
#expect(agentAutomationRunOutcome(status: "error", error: nil) == .failure)
#expect(agentAutomationRunOutcome(status: "ok", error: "delivery failed") == .failure)
#expect(agentAutomationRunOutcome(status: nil, error: nil) == .unknown)
#expect(agentAutomationRunOutcome(status: "future-status", error: nil) == .unknown)
}
@Test func `invalid spec skip refreshes persisted diagnostics`() {
#expect(agentAutomationRunSkipShouldRefresh(reason: "invalid-spec"))
#expect(!agentAutomationRunSkipShouldRefresh(reason: "already-running"))
#expect(!agentAutomationRunSkipShouldRefresh(reason: nil))
}
@Test @MainActor func `queued run registry keeps exact reservation until terminal release`() {
let registry = AgentAutomationPendingRunRegistry()
#expect(registry.reserve(jobID: "job-1", runID: "run-1"))
#expect(!registry.reserve(jobID: "job-1", runID: "run-2"))
#expect(registry.runID(for: "job-1") == "run-1")
registry.release(jobID: "job-1", runID: "run-2")
#expect(registry.runID(for: "job-1") == "run-1")
registry.release(jobID: "job-1", runID: "run-1")
#expect(registry.runID(for: "job-1") == nil)
}
@Test func `successful delete-after-run one-shot dismisses`() {
let oneShot = Self.job(
schedule: AnyCodable(["kind": AnyCodable("at"), "at": AnyCodable("2026-07-14T16:00:00Z")]),
deleteAfterRun: true)
#expect(agentAutomationDeletesAfterSuccessfulRun(job: oneShot, outcome: .success))
#expect(!agentAutomationDeletesAfterSuccessfulRun(job: oneShot, outcome: .failure))
#expect(!agentAutomationDeletesAfterSuccessfulRun(job: Self.job(), outcome: .success))
}
@Test func `semantic dirty state ignores normalized no-op edits`() throws {
let job = Self.job()
var draft = try #require(AgentAutomationDraft(job: job))
draft.name = " Release briefing "
#expect(!agentAutomationHasSemanticChanges(job: job, draft: draft))
draft.name = "Release briefing v2"
#expect(agentAutomationHasSemanticChanges(job: job, draft: draft))
}
@Test func `automation pagination requires a forward offset`() {
let page = CronJobsListLite(jobs: [], total: 201, hasMore: true, nextOffset: 200)
#expect(nextCronJobsListOffset(page: page, currentOffset: 0) == 200)
#expect(nextCronJobsListOffset(page: page, currentOffset: 200) == nil)
}
@Test func `legacy cron list defaults to a single page`() throws {
let page = try JSONDecoder().decode(
CronJobsListLite.self,
from: Data(#"{"jobs":[],"total":0}"#.utf8))
#expect(page.snapshotRevision == nil)
#expect(!page.hasMore)
#expect(page.nextOffset == nil)
}
@Test func `automation pagination pins total and snapshot revision`() throws {
let first = try JSONDecoder().decode(
CronJobsListLite.self,
from: Data(#"{"jobs":[],"snapshotRevision":" rev-1 ","total":201,"hasMore":true,"nextOffset":200}"#.utf8))
let changedRevision = CronJobsListLite(
jobs: [],
snapshotRevision: "rev-2",
total: 201,
hasMore: false,
nextOffset: nil)
let changedTotal = CronJobsListLite(
jobs: [],
snapshotRevision: "rev-1",
total: 200,
hasMore: false,
nextOffset: nil)
let identity = try #require(cronJobsSnapshotIdentity(page: first, maximumCount: 20000))
#expect(identity == CronJobsSnapshotIdentity(total: 201, revision: "rev-1"))
#expect(cronJobsSnapshotIdentity(page: changedRevision, maximumCount: 20000) != identity)
#expect(cronJobsSnapshotIdentity(page: changedTotal, maximumCount: 20000) != identity)
let legacyTerminal = try JSONDecoder().decode(
CronJobsListLite.self,
from: Data(#"{"jobs":[]}"#.utf8))
#expect(cronJobsSnapshotIdentity(page: legacyTerminal, maximumCount: 20000) ==
CronJobsSnapshotIdentity(total: nil, revision: nil))
}
@Test func `automation editor selection preserves tapped snapshot`() {
let job = Self.job()
let selection = AgentProTab.AutomationEditorSelection(
initialJob: job,
sourceGatewayID: "gateway-a")
#expect(selection.id == job.id)
#expect(selection.initialJob.name == job.name)
#expect(selection.sourceGatewayID == "gateway-a")
}
@Test func `detail source guards route and exact queued run`() throws {
let source = try String(
contentsOf: Self.sourceURL("Design/AgentAutomationDetailScreen.swift"),
encoding: .utf8)
let models = try String(
contentsOf: Self.sourceURL("Design/AgentAutomationModels.swift"),
encoding: .utf8)
#expect(source.contains("distinguishPreDispatchRouteChange: true"))
#expect(source.contains("currentRoute() == route"))
#expect(source.contains("gatewayChangedAfterDispatch"))
#expect(source.contains("ifGatewayID: self.sourceGatewayID"))
#expect(source.contains("\"runId\": runID"))
#expect(source.contains("pendingRunRegistry"))
#expect(!source.contains("self.pendingRunID = nil"))
#expect(source.contains("self.pendingRunRegistry.release(jobID: self.job.id, runID: runID)"))
#expect(source.contains("\"expectedProcessInstanceId\": processInstanceID"))
#expect(source.contains("guard self.pendingRunID == runID else { return }"))
#expect(models.contains("expectedConfigRevision"))
#expect(source.contains("Delete Automation"))
#expect(source.contains("OpenClawType.subheadSemiBold"))
#expect(source.contains("!self.hasUnsavedChanges"))
let tabSource = try String(
contentsOf: Self.sourceURL("Design/AgentProTab.swift"),
encoding: .utf8)
let cronSource = try String(
contentsOf: Self.sourceURL("Design/AgentProTab+Cron.swift"),
encoding: .utf8)
#expect(tabSource.contains("initialJob: selection.initialJob"))
#expect(!tabSource.contains("overview.cronJobs.first(where:"))
#expect(cronSource.contains("sourceGatewayID: sourceGatewayID"))
#expect(cronSource.contains("guard pendingCronRuns.runID(for: job.id) == nil else { return }"))
#expect(cronSource.contains("self.pendingCronRuns.reserve(jobID: jobID, runID: runID)"))
#expect(cronSource.contains("entries.contains(where: { $0.runid == runID })"))
#expect(cronSource.contains("method: \"system.info\""))
#expect(cronSource.contains("\"expectedProcessInstanceId\": processInstanceID"))
#expect(cronSource.contains("guard currentInstanceID == processInstanceID else"))
#expect(cronSource
.contains(
"presentAutomationEditor(\n job: job,\n sourceGatewayID: sourceGatewayID"))
}
private static func job(
payload: AnyCodable? = nil,
schedule: AnyCodable? = nil,
deleteAfterRun: Bool = false) -> CronJob
{
CronJob(
id: "release-briefing",
name: "Release briefing",
description: "Daily mobile release overview",
enabled: true,
deleteafterrun: deleteAfterRun,
createdatms: 1_783_468_800_000,
updatedatms: 1_783_555_200_000,
configrevision: "sha256:test-revision",
schedule: schedule ?? AnyCodable([
"kind": AnyCodable("every"),
"everyMs": AnyCodable(86_400_000),
"anchorMs": AnyCodable(1_783_468_800_000),
]),
sessiontarget: AnyCodable("isolated"),
wakemode: AnyCodable("now"),
payload: payload ?? AnyCodable([
"kind": AnyCodable("agentTurn"),
"message": AnyCodable("Summarize release readiness."),
"model": AnyCodable("openai/gpt-5.2"),
]),
state: [:],
nextrunatms: 1_783_641_600_000)
}
private static func sourceURL(_ path: String) -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources")
.appendingPathComponent(path)
}
}