import XCTest @testable import LChatCore // 🤖 The Swift half of the flow-interpreter drift gate: runs the REAL bundled // flow documents (Flows/*.json — byte-derived from *.flow.js, enforced by // test/flow.test.mjs) through FlowEngine with a scripted io, mirroring // test/flow.test.mjs's harness. The native runs here are the ones the app // ships: env.native skips every passkey step. @MainActor final class FlowEngineTests: XCTestCase { // MARK: harness (the Swift twin of flow.test.mjs's harness()) final class Harness: FlowIO { struct Line { let text: String let chips: [FlowChip]? let extra: FlowSayExtra } var transcript: [Line] = [] var records: [(step: String, label: String, value: String)] = [] var verbLog: [(String, [FlowJSON])] = [] var doneSteps: [String: Bool] = [:] var answerHandler: ((FlowAnswer) -> Void)? var cancelled = 0 var pendingDelays: [(ms: Double, fn: () -> Void)] = [] var verbResults: [String: FlowJSON] = [:] var ended = false func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async { transcript.append(Line(text: text, chips: chips, extra: extra)) } func ask(_ handler: @escaping (FlowAnswer) -> Void) { answerHandler = handler } func cancelAnswer() { cancelled += 1 answerHandler = nil } func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON { verbLog.append((name, args)) return verbResults[name] ?? .null } func record(step: String, label: String, value: String) async { records.append((step, label, value)) doneSteps[step] = true } func done(_ step: String) -> Bool { doneSteps[step] ?? false } func delay(ms: Double, _ fn: @escaping () -> Void) { pendingDelays.append((ms, fn)) } var lastChips: [FlowChip]? { transcript.reversed().first(where: { $0.chips != nil })?.chips } func tap(_ label: String) async { guard let chip = lastChips?.first(where: { $0.label == label }) else { return XCTFail("no chip \(label); have \(lastChips?.map(\.label) ?? [])") } chip.run() await settle() } func answer(_ text: String, image: [String: String]? = nil) async { guard let h = answerHandler else { return XCTFail("no answer waiter armed") } answerHandler = nil h(FlowAnswer(text: text, imageAttachment: image)) await settle() } // Let the engine's fire-and-forget Tasks drain (all MainActor). func settle() async { for _ in 0..<40 { await Task.yield() } } } // The Flows resources live in the LChatCore module bundle (AppFlows reads // Bundle.module from inside the library, which resolves correctly here). func moduleFlow(_ name: String) throws -> FlowJSON { try XCTUnwrap(AppFlows.flow(name), "bundled flow \(name).json") } func nativeEnv(name: String = "", space: String = "Test Space", homeSignedIn: Bool = true) -> FlowEnv { let env = FlowEnv() env.flags["native"] = .bool(true) env.flags["suggested"] = .string("") env.flags["inviterName"] = .string("") env.flags["adminName"] = .string("") env.flags["hasPendingThread"] = .bool(false) env.space = ["name": .string(space), "nameSet": .bool(!space.isEmpty)] env.live["bridge.myName"] = { .string(name) } env.live["bridge.enrolled"] = { .bool(false) } env.live["bridge.fromHome"] = { .bool(false) } // Matches the JS harness default. Without it every flow read // "!bridge.canEnrol" as TRUE — a self-hosted space with no sign-in to // offer — so the welcome access step recorded "your access lives in // this browser" and skipped past its chips, quietly rotting // testQuickFormTrailingChip (red on main since the #1377 gate landed). env.live["bridge.canEnrol"] = { .bool(true) } // Mirrors ElevenConversationModel: both read the live home session. env.live["bridge.homeSignedIn"] = { .bool(homeSignedIn) } env.live["accessBackedUp"] = { .bool(homeSignedIn) } return env } // MARK: template + condition mini-language (mirrors the JS test) func testTemplateAndConditions() throws { let env = FlowEnv() env.vars["x"] = .string("hi") env.space = ["name": .string("")] env.ack = "A" let engine = FlowEngine(flow: .object([:]), env: env, io: Harness()) XCTAssertEqual(engine.renderText(.string("{vars.x} {space.name|fallback}")), "hi fallback") XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "A\n\nrest") XCTAssertEqual(engine.renderText(.string("{ack?}rest")), "rest", "ack is consumed") XCTAssertEqual(engine.renderText(.array([.string("a"), .object(["if": .string("vars.x"), "text": .string("-b")]), .object(["if": .string("vars.missing"), "text": .string("-c")])])), "a-b") XCTAssertEqual(engine.renderText(.object(["if": .string("vars.missing"), "then": .string("T"), "else": .string("E")])), "E") XCTAssertTrue(engine.evalCond(.string("!vars.missing"))) } // MARK: the native creator setup run (mirrors the JS native test) func testNativeSetupRun() async throws { let flow = try moduleFlow("setup") let io = Harness() io.verbResults["setGroupName"] = .string("ok") let env = nativeEnv(name: "Paul") let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } // First run WITH a name: greeted (not "resumed"), name step skipped. XCTAssertTrue(io.transcript.first?.text.hasPrefix("Hi — I'm Eleven! 👋") ?? false) XCTAssertFalse(io.transcript.contains { $0.text.contains("Welcome back") }) XCTAssertFalse(io.transcript.contains { $0.text.contains("what should everyone here call you") }) XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" }) await io.answer("Team Awesome") env.space["name"] = .string("Team Awesome") // the web bridge updates space; the harness mirrors it await io.tap("Skip for now") // email; the photo step auto-skips under native XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) await io.tap("Members can invite people") // The extras trio (mirrors the JS native run): the one-tap common path // records and writes no gates — defaults are already on server-side. await io.tap("Keep both on") XCTAssertTrue(io.records.contains { $0.step == "extras" && $0.value == "all-on" }) XCTAssertFalse(io.verbLog.contains { $0.0 == "setFeature" }) // The AI opt-in step (#877) runs under native too — mirror the JS twin. await io.tap("Not now") // Keys: never pitched — recorded as covered by the Eleven account. XCTAssertFalse(io.transcript.contains { $0.text.contains("Now the most important thing of all") }) XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }) let keys = io.records.first { $0.step == "keys" } XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account") XCTAssertEqual(keys?.value, "native") // Finish: completion marker, setup marked complete, no backup nag, no // cross-thread admin chip. XCTAssertEqual(io.records.last?.step, "finish") XCTAssertEqual(io.records.last?.value, "done") XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }) XCTAssertFalse(io.verbLog.contains { $0.0 == "deferBackupReminders" }) XCTAssertFalse(io.transcript.contains { $0.text.contains("One more reminder") }) // The tour stays web-only; Admin hops natively now. let allChips = io.transcript.flatMap { $0.chips ?? [] }.map(\.label) XCTAssertFalse(allChips.contains("Take the tour")) XCTAssertTrue(allChips.contains("Open admin")) } // MARK: native without an Eleven session // Twin of "setup: native without an Eleven session says where the key // really is". Claiming a space pushes it into the account's vault through // HomeSync, which no-ops silently when signed out — so the conversation // must say where the key actually is instead of naming an account. func testNativeWithoutHomeSessionIsHonest() async throws { let flow = try moduleFlow("setup") let io = Harness() io.verbResults["setGroupName"] = .string("ok") let env = nativeEnv(name: "Paul", homeSignedIn: false) // A host that wired NO signInToEleven (an iOS 17 device, say): the // model reports canEnrol false and the flow must record where the key // is rather than pitch something that goes nowhere. env.live["bridge.canEnrol"] = { .bool(false) } let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } await io.answer("Team Awesome") env.space["name"] = .string("Team Awesome") await io.tap("Skip for now") await io.tap("Members can invite people") await io.tap("Keep both on") await io.tap("Not now") let keys = io.records.first { $0.step == "keys" } XCTAssertEqual(keys?.label, "This device holds this space's key") XCTAssertEqual(keys?.value, "local") XCTAssertFalse(io.verbLog.contains { $0.0 == "savePasskey" }, "no dead sign-in chip") XCTAssertFalse(io.transcript.flatMap { $0.chips ?? [] }.map(\.label).contains("Sign in")) XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "still reaches finish") } // A native host that CAN sign in (signInToEleven wired → canEnrol true) // pitches it in the app's own words, and settles the moment the session // lands — no "Continue" tap. Twin of "setup: native signed out — pitched // in device words, settles on the sign-in". func testNativeSignedOutIsPitchedAndSettles() async throws { let flow = try moduleFlow("setup") let io = Harness() io.verbResults["setGroupName"] = .string("ok") var signedIn = false let env = nativeEnv(name: "Paul", space: "") env.live["bridge.homeSignedIn"] = { .bool(signedIn) } env.live["accessBackedUp"] = { .bool(signedIn) } env.live["bridge.canEnrol"] = { .bool(true) } let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } await io.answer("Team Awesome") env.space["name"] = .string("Team Awesome") await io.tap("Skip for now") await io.tap("Members can invite people") await io.tap("Keep both on") await io.tap("Not now") let pitch = io.transcript.first { $0.text.contains("Now the most important thing of all") } XCTAssertNotNil(pitch, "the key pitch runs") XCTAssertTrue(pitch?.text.contains("this device holds the only key") ?? false, "device words, not browser words") await io.tap("Sign in") XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" }, "the host is asked to open its sign-in") // The app signs in; the step's settle poll notices on its own. signedIn = true let poll = io.pendingDelays.last { $0.0 == 700 } XCTAssertNotNil(poll, "the keys step arms a settle poll") poll?.1() await io.settle() let keys = io.records.first { $0.step == "keys" } XCTAssertEqual(keys?.label, "Your space's key is saved with your Eleven account") XCTAssertEqual(keys?.value, "native") XCTAssertTrue(io.verbLog.contains { $0.0 == "markSetupComplete" }, "carried on to finish") } // MARK: input.confirm — an email answered as a name // Twin of "setup: an email answered as a name is confirmed, not swallowed" // in test/flow.test.mjs. Both engines must hold the answer back until the // person confirms it: nothing recorded, no setMyName, no move on. func testEmailAnsweredAsNameIsConfirmed() async throws { let flow = try moduleFlow("setup") let io = Harness() io.verbResults["setMyName"] = .string("ok") io.verbResults["setGroupName"] = .string("ok") let env = nativeEnv(name: "", space: "") // no name yet: the question is asked let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } await io.answer("paul@rslw.com") XCTAssertTrue(io.transcript.last?.text.contains("That's an email address") ?? false, "the confirm step must come up") XCTAssertFalse(io.verbLog.contains { $0.0 == "setMyName" }, "nothing set yet") XCTAssertFalse(io.records.contains { $0.step == "name" }, "nothing recorded yet") await io.tap("Use it anyway") XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" }) XCTAssertEqual(io.records.filter { $0.step == "name" }.count, 1, "the confirm records the NAME step, once") XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-group" }, "carried on to the space name") } // Twin of the looksLikeEmail cases in test/flow.test.mjs. func testLooksLikeEmail() { for yes in ["paul@rslw.com", " Ada@example.co.uk ", "a.b+c@sub.domain.org"] { XCTAssertTrue(looksLikeEmail(yes), yes) } for no in ["Ada", "Ada @ the shop", "@ada", "ada@", "ada@localhost", "", "two@parts@here.com"] { XCTAssertFalse(looksLikeEmail(no), no) } } // MARK: the native joiner welcome run // Twin of the cleanNameAnswer cases in test/flow.test.mjs — both engines // must clean a conversational name reply identically. func testCleanNameAnswer() { XCTAssertEqual(cleanNameAnswer("I'm Dermot"), "Dermot") XCTAssertEqual(cleanNameAnswer("I\u{2019}m Dermot"), "Dermot") // iOS smart apostrophe XCTAssertEqual(cleanNameAnswer("i am dermot"), "dermot") // case kept as typed XCTAssertEqual(cleanNameAnswer("My name is Ana."), "Ana") XCTAssertEqual(cleanNameAnswer("Hi, I'm Dermot!"), "Dermot") XCTAssertEqual(cleanNameAnswer("hello my name's Bo"), "Bo") XCTAssertEqual(cleanNameAnswer("you can call me Dermo"), "Dermo") XCTAssertEqual(cleanNameAnswer("It's Maud"), "Maud") XCTAssertEqual(cleanNameAnswer("Im Dermot"), "Dermot") // apostrophe skipped XCTAssertEqual(cleanNameAnswer("Dermot"), "Dermot") XCTAssertEqual(cleanNameAnswer(" Dermot "), "Dermot") XCTAssertEqual(cleanNameAnswer("Hilary"), "Hilary") // "hi" needs a boundary XCTAssertEqual(cleanNameAnswer("Ima Jean"), "Ima Jean") // "im" needs a space XCTAssertEqual(cleanNameAnswer("Mary-Kate O'Brien"), "Mary-Kate O'Brien") // Stripping must never clean a name to nothing — keep the original. XCTAssertEqual(cleanNameAnswer("I'm"), "I'm") XCTAssertEqual(cleanNameAnswer(""), "") } func testNativeWelcomeRun() async throws { let flow = try moduleFlow("welcome") let io = Harness() let env = nativeEnv() env.flags["inviterName"] = .string("Ciara") let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } // Greeting names the inviter; the passkey ask never renders; access // records the native reason; the name question is armed. XCTAssertTrue(io.transcript.first?.text.contains("Ciara invited you to Test Space") ?? false) XCTAssertFalse(io.transcript.contains { $0.text.contains("Sign in with Eleven and your access is safe") }) let access = io.records.first XCTAssertEqual(access?.step, "access") XCTAssertEqual(access?.value, "native") XCTAssertTrue(io.transcript.contains { $0.extra.key == "q-name" }) // A conversational reply is cleaned to the bare name (input.clean). await io.answer("Hi, I'm Paul!") XCTAssertTrue(io.verbLog.contains { $0.0 == "setMyName" && $0.1.first == .string("Paul") }) // The photo step auto-skips under native — finish follows directly. XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) // Finish: no backup nag (access is the sign-in), completion recorded. let fin = io.transcript.first { $0.extra.key == "q-finish" } XCTAssertNotNil(fin) XCTAssertFalse(fin?.text.contains("One more reminder") ?? true) XCTAssertEqual(io.records.last?.step, "finish") XCTAssertEqual(io.records.last?.value, "done") XCTAssertTrue(io.ended == false) // onEnd fires on env, not io — checked below } // MARK: the quick-form fast path (mirrors the JS trailing-chip test) func testQuickFormTrailingChip() async throws { // A web-shaped env (native=false): the access step renders its chips — // including the trailing quick-form chip, whose tap fires the // openQuickForm verb WITHOUT consuming the step. let flow = try moduleFlow("welcome") let io = Harness() let env = nativeEnv() env.flags["native"] = .bool(false) let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } let chip = io.lastChips?.first { $0.label == "Fill in a form instead" } XCTAssertNotNil(chip, "the welcome access step offers the quick-form chip") XCTAssertEqual(chip?.trailing, true, "flagged trailing — the renderer right-aligns it") XCTAssertEqual(io.lastChips?.first { $0.label == "Sign in (recommended)" }?.trailing, false, "primary chips stay untagged") await io.tap("Fill in a form instead") XCTAssertTrue(io.verbLog.contains { $0.0 == "openQuickForm" }) await io.tap("Sign in (recommended)") // non-advancing: the step is still live XCTAssertTrue(io.verbLog.contains { $0.0 == "savePasskey" }) // Setup: the name step carries the same chip. let flow2 = try moduleFlow("setup") let io2 = Harness() let env2 = nativeEnv() env2.flags["native"] = .bool(false) let engine2 = FlowEngine(flow: flow2, env: env2, io: io2) engine2.start() await io2.settle() defer { withExtendedLifetime(engine2) {} } let chip2 = io2.lastChips?.first { $0.label == "Fill in a form instead" } XCTAssertNotNil(chip2, "the setup name step offers the quick-form chip") XCTAssertEqual(chip2?.trailing, true) await io2.tap("Fill in a form instead") XCTAssertTrue(io2.verbLog.contains { $0.0 == "openQuickForm" }) } // MARK: resume semantics func testResumeSkipsAnsweredSteps() async throws { let flow = try moduleFlow("welcome") let io = Harness() io.doneSteps = ["access": true, "name": true] let env = nativeEnv(name: "Paul") var ended = false env.onEnd = { ended = true } let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } // Resumed: the re-greeting, hello skipped; photo auto-skips under // native, so the flow runs straight to finish and ends. XCTAssertTrue(io.transcript.first?.text.hasPrefix("Welcome back") ?? false) XCTAssertTrue(io.transcript.first?.extra.local ?? false) XCTAssertFalse(io.transcript.contains { $0.text.hasPrefix("Welcome! 👋") }) XCTAssertFalse(io.transcript.contains { $0.extra.key == "q-photo" }) XCTAssertTrue(io.records.contains { $0.step == "finish" }) XCTAssertTrue(ended, "end step fires env.onEnd") } // MARK: a finished conversation never replays func testFinishedConversationEntersAndEndsQuietly() async throws { let flow = try moduleFlow("welcome") let io = Harness() io.doneSteps = ["access": true, "name": true, "photo": true, "finish": true] let env = nativeEnv(name: "Paul") let engine = FlowEngine(flow: flow, env: env, io: io) engine.start() await io.settle() defer { withExtendedLifetime(engine) {} } // Finish is already recorded: no new records, and the only lines are // the resume re-greeting + finish bubbles (chips stay live). XCTAssertFalse(io.records.contains { $0.step != "finish" }) XCTAssertEqual(io.records.count, 0) } }