Skip to content
Draft
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
24 changes: 23 additions & 1 deletion apps/ios/ModexCompanion/Scripts/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import net from "node:net";
import http from "node:http";
import https from "node:https";
import { randomUUID } from "node:crypto";
import { Store } from "../../../desktop/dist/src/main/engine/store.js";
import { ThreadRunner } from "../../../desktop/dist/src/main/engine/runner.js";
Expand All @@ -16,6 +17,20 @@ import { EventJournal } from "../../../desktop/dist/src/main/engine/event-journa
// with what the process did meanwhile: CPU time means our own work, major faults paging, neither a
// starved process.
const log = (message) => process.stderr.write(`[fixture ${new Date().toISOString()}] ${message}\n`);
// Test-process transport fault: consume exactly one armed command GET without answering.
// The phone's unchanged request timeout must show recovery inside the picker. All other
// traffic, including snapshot polling and mutations, keeps the real CompanionServer path.
let stallNextCommands = false;
const createHttpsServer = https.createServer;
https.createServer = (options, listener) => createHttpsServer(options, (request, response) => {
if (stallNextCommands && request.method === "GET" && request.url?.startsWith("/v1/commands?")) {
stallNextCommands = false;
log("holding one command GET until the phone times out");
request.on("close", () => log("held command GET closed"));
return;
}
listener(request, response);
});
let lastTick = performance.now();
let lastUsage = process.resourceUsage();
setInterval(() => {
Expand Down Expand Up @@ -134,6 +149,12 @@ const resumePath = `/resume/${randomUUID()}`;
// The test closes a question on the Mac: POST <macPath>/answer or <macPath>/stop.
const macPath = `/mac/${randomUUID()}`;
const control = http.createServer(async (request, response) => {
if (request.method === "POST" && request.url === `${macPath}/timeout-commands`) {
stallNextCommands = true;
log("armed one command GET timeout");
response.writeHead(204).end();
return;
}
if (request.method === "POST" && (request.url === `${macPath}/answer` || request.url === `${macPath}/stop`)) {
const how = request.url.endsWith("/answer") ? "answer" : "stop";
const id = macThreads[how];
Expand Down Expand Up @@ -174,7 +195,7 @@ const control = http.createServer(async (request, response) => {
await new Promise((resolve) => control.listen(0, "127.0.0.1", resolve));
const resumeURL = `http://127.0.0.1:${control.address().port}${resumePath}`;
const macURL = `http://127.0.0.1:${control.address().port}${macPath}`;
fs.writeFileSync(process.argv[2], JSON.stringify({ link: server.status().pairingUri, reconnect, resumeURL, macURL }));
fs.writeFileSync(process.argv[2], JSON.stringify({ link: server.status().pairingUri, reconnect, resumeURL, macURL, defaultProjectName: store.snapshot().projects[0]?.name }));

async function pauseMac() {
const resumed = new Promise((resolve) => { resumePausedMac = resolve; });
Expand Down Expand Up @@ -204,6 +225,7 @@ async function restartMac() {

async function close() {
closing = true;
https.createServer = createHttpsServer;
clearTimeout(restartTimer);
resumePausedMac?.();
await restartTask;
Expand Down
3 changes: 3 additions & 0 deletions apps/ios/ModexCompanion/Scripts/test-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ if [[ -z "$xctestrun" ]]; then echo "Xcode did not create an iPhone test plan."
# UITestBudget in UITests/XCUIElement+Ready.swift). Every UI-test wait is multiplied by this;
# xcodebuild hands TEST_RUNNER_* variables to the test runner's environment.
timeout_scale="${MODEX_UI_TIMEOUT_SCALE:-1}"
mkdir -p "$derived_data/Logs/Test"
result_bundle="$derived_data/Logs/Test/Paired-$(date -u +%Y%m%dT%H%M%SZ)-$$.xcresult"
TEST_RUNNER_MODEX_UI_TIMEOUT_SCALE="$timeout_scale" xcodebuild -xctestrun "$xctestrun" -destination "$destination" \
-resultBundlePath "$result_bundle" \
-parallel-testing-enabled NO -only-testing:ModexCompanionUITests/DemoWorkspaceTests -only-testing:ModexCompanionUITests/CompanionFlowTests test-without-building
14 changes: 12 additions & 2 deletions apps/ios/ModexCompanion/Sources/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import SwiftUI
private var answerOperation: UUID?
private var commandOperation: UUID?
private var commandContext: String?
@Published private var commandFailure: (context: String, message: String)?

init(store: any PairingStorage = PairingStore(), discovery: (any CompanionDiscovery)? = nil, defaults: UserDefaults = .standard, makeClient: @escaping (Pairing) -> any CompanionClient = { CompanionAPI(pairing: $0) }) {
self.store = store
Expand Down Expand Up @@ -218,6 +219,7 @@ import SwiftUI
answerOperation = nil
commandOperation = nil
commandContext = nil
commandFailure = nil
retrySavedEndpoint = false
sending = false
answeringId = nil
Expand Down Expand Up @@ -372,22 +374,30 @@ import SwiftUI
let operation = UUID()
let context = "\(projectId)\u{0}\(backend)"
commandOperation = operation
if commandContext != context { commands = [] }
commandContext = context
commands = []
commandFailure = nil
do {
let result = try await api.commands(projectId: projectId, backend: backend)
if connection == connectionRevision, commandOperation == operation, !Task.isCancelled {
commands = result
commandContext = context
}
} catch {
if connection == connectionRevision, commandOperation == operation, !Task.isCancelled, !revokeIfNeeded(error) { self.error = error.localizedDescription }
if connection == connectionRevision, commandOperation == operation, !Task.isCancelled, !revokeIfNeeded(error) {
commandFailure = (context, error.localizedDescription)
}
}
}

func availableCommands(projectId: String, backend: String) -> [CompanionCommand] {
commandContext == "\(projectId)\u{0}\(backend)" ? commands : []
}

func commandError(projectId: String, backend: String) -> String? {
commandFailure?.context == "\(projectId)\u{0}\(backend)" ? commandFailure?.message : nil
}

func answer(itemId: String, approve: Bool) async {
guard let api, let selectedThreadId, answeringId == nil else { return }
let connection = connectionRevision
Expand Down
22 changes: 21 additions & 1 deletion apps/ios/ModexCompanion/Sources/CompanionViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ private struct CommandSheet: View {
let onSelect: (String) -> Void
@State private var query = ""
@State private var loading = true
@State private var retry = 0

private var filtered: [CompanionCommand] {
let commands = model.availableCommands(projectId: projectId, backend: backend)
Expand All @@ -684,6 +685,17 @@ private struct CommandSheet: View {
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 14))
if loading {
ProgressView().tint(Palette.accent).frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = model.commandError(projectId: projectId, backend: backend) {
VStack(spacing: 12) {
Text("Couldn’t load skills and commands").font(.headline)
Text(error).font(.subheadline).foregroundStyle(Palette.muted)
Button("Try again") { retry += 1 }
.buttonStyle(.bordered)
.frame(minHeight: 44)
.accessibilityIdentifier("retry-commands")
}
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if filtered.isEmpty {
ContentUnavailableView("No commands found", systemImage: "command", description: Text("Install skills on your Mac or type a command directly."))
.foregroundStyle(Palette.muted)
Expand Down Expand Up @@ -715,7 +727,7 @@ private struct CommandSheet: View {
.toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() }.foregroundStyle(Palette.muted) } }
}
.presentationDetents([.medium, .large])
.task(id: "\(projectId)\u{0}\(backend)") {
.task(id: "\(projectId)\u{0}\(backend)\u{0}\(retry)") {
loading = true
await model.loadCommands(projectId: projectId, backend: backend)
if !Task.isCancelled { loading = false }
Expand Down Expand Up @@ -803,6 +815,7 @@ private struct QuestionCard: View {
@EnvironmentObject private var model: CompanionModel
let item: CompanionItem
let busy: Bool
@FocusState private var focusedSecret: String?

var body: some View {
VStack(alignment: .leading, spacing: 15) {
Expand Down Expand Up @@ -889,6 +902,9 @@ private struct QuestionCard: View {
Group {
if question.secret {
SecureField(prompt, text: text).textContentType(.password)
.focused($focusedSecret, equals: question.id)
.submitLabel(.done)
.onSubmit { focusedSecret = nil }
} else {
TextField(prompt, text: text, axis: .vertical).lineLimit(1...4)
}
Expand Down Expand Up @@ -948,6 +964,7 @@ private struct PairingSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var link = ""
@State private var scanning = false
@FocusState private var linkFocused: Bool

var body: some View {
NavigationStack {
Expand All @@ -969,6 +986,9 @@ private struct PairingSheet: View {
TextField("modex://pair?data=…", text: $link)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.keyboardType(.URL).font(.system(size: 13, design: .monospaced))
.focused($linkFocused)
.submitLabel(.done)
.onSubmit { linkFocused = false }
.padding(15).background(Palette.raised, in: RoundedRectangle(cornerRadius: 13))
.accessibilityIdentifier("pairing-link-input")
Button(model.isPairing ? "Connecting…" : "Connect") { Task { if await model.open(link: link) { dismiss() } } }
Expand Down
72 changes: 72 additions & 0 deletions apps/ios/ModexCompanion/Tests/CompanionModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,76 @@ import XCTest
XCTAssertTrue(model.availableCommands(projectId: "p", backend: "codex").isEmpty)
}

func testCommandTimeoutKeepsTheDraftAndDoesNotPresentTheRootAlert() async {
let (model, client) = fixture()
model.newThreadDraft = "Start from phone"
let pending = Pending<[CompanionCommand]>(requested: expectation(description: "commands time out"))
await client.enqueueCommands(pending)
let request = Task { await model.loadCommands(projectId: "p", backend: "codex") }
await fulfillment(of: [pending.requested], timeout: 2)
await pending.resolve(.failure(URLError(.timedOut)))
await request.value

XCTAssertNil(model.error, "A command-list failure must stay in the picker; the root alert dismisses the new-thread sheet.")
XCTAssertNotNil(model.commandError(projectId: "p", backend: "codex"))
XCTAssertNil(model.commandError(projectId: "other", backend: "codex"))
XCTAssertNil(model.commandError(projectId: "p", backend: "claude"))
XCTAssertEqual(model.newThreadDraft, "Start from phone")
XCTAssertTrue(model.availableCommands(projectId: "p", backend: "codex").isEmpty)
XCTAssertNil(client.createdText, "Loading skills must never submit the draft.")
let beforeRetry = await client.calls
XCTAssertEqual(beforeRetry, ["commands:p:codex"], "A failed read must not retry itself.")

let retry = Pending<[CompanionCommand]>(requested: expectation(description: "explicit command retry"))
await client.enqueueCommands(retry)
let retried = Task { await model.loadCommands(projectId: "p", backend: "codex") }
await fulfillment(of: [retry.requested], timeout: 2)
XCTAssertNil(model.commandError(projectId: "p", backend: "codex"))
let command = CompanionCommand(id: "skill:mobile-check", title: "mobile-check", detail: "Verify", insertion: "$mobile-check ", kind: "skill")
await retry.resolve(.success([command]))
await retried.value
XCTAssertEqual(model.availableCommands(projectId: "p", backend: "codex"), [command])
XCTAssertEqual(model.newThreadDraft, "Start from phone")
XCTAssertNil(client.createdText, "Retrying a read must not submit the draft either.")
let afterRetry = await client.calls
XCTAssertEqual(afterRetry, ["commands:p:codex", "commands:p:codex"])
}

func testLateCommandFailureCannotReplaceAnotherProjectsResults() async {
let (model, client) = fixture()
let old = Pending<[CompanionCommand]>(requested: expectation(description: "old project commands"))
let current = Pending<[CompanionCommand]>(requested: expectation(description: "new project commands"))
await client.enqueueCommands(old)
await client.enqueueCommands(current)
let previous = Task { await model.loadCommands(projectId: "p", backend: "codex") }
await fulfillment(of: [old.requested], timeout: 2)
let next = Task { await model.loadCommands(projectId: "other", backend: "codex") }
await fulfillment(of: [current.requested], timeout: 2)
let command = CompanionCommand(id: "skill:other", title: "other", detail: "Other project", insertion: "$other ", kind: "skill")
await current.resolve(.success([command]))
await next.value
await old.resolve(.failure(URLError(.timedOut)))
await previous.value
XCTAssertEqual(model.availableCommands(projectId: "other", backend: "codex"), [command])
XCTAssertNil(model.commandError(projectId: "other", backend: "codex"))
XCTAssertNil(model.commandError(projectId: "p", backend: "codex"))
XCTAssertNil(model.error)
}

func testCommandRevocationStillForgetsTheMac() async {
let (model, client) = fixture()
let pending = Pending<[CompanionCommand]>(requested: expectation(description: "commands revoked"))
await client.enqueueCommands(pending)
let request = Task { await model.loadCommands(projectId: "p", backend: "codex") }
await fulfillment(of: [pending.requested], timeout: 2)
await pending.resolve(.failure(CompanionError.accessRevoked))
await request.value
XCTAssertNil(model.pairing)
XCTAssertFalse(model.connected)
XCTAssertNotNil(model.error, "Revocation must retain the existing pairing recovery alert.")
XCTAssertNil(model.commandError(projectId: "p", backend: "codex"))
}

func testCancelledCommandPickerDoesNotPublishResultsOrErrors() async {
let command = CompanionCommand(id: "skill:old", title: "old", detail: "Skill", insertion: "$old ", kind: "skill")
for result: Result<[CompanionCommand], Error> in [.success([command]), .failure(CancellationError())] {
Expand All @@ -435,6 +505,7 @@ import XCTest
await request.value
XCTAssertNil(model.error)
XCTAssertTrue(model.commands.isEmpty)
XCTAssertNil(model.commandError(projectId: "p", backend: "codex"))
}
}

Expand Down Expand Up @@ -905,6 +976,7 @@ private actor ControlledClient: CompanionClient {
return createdThread!
}
func commands(projectId: String, backend: String) async throws -> [CompanionCommand] {
calls.append("commands:\(projectId):\(backend)")
if !commandLists.isEmpty { return try await commandLists.removeFirst().value() }
return []
}
Expand Down
Loading
Loading