ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s
TestFlight / Build and upload (push) Successful in 1m48s
This commit is contained in:
@@ -5,6 +5,10 @@ Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
import unicodedata
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
from aiohttp import web
|
||||
|
||||
sockets = set()
|
||||
@@ -16,6 +20,21 @@ busy = False
|
||||
attempts = {}
|
||||
log = []
|
||||
reply_tasks = set()
|
||||
attachments = {}
|
||||
uploads = {}
|
||||
attachment_capability = True
|
||||
next_upload_error = None
|
||||
blocked_proxy = False
|
||||
transcript_comparison = False
|
||||
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
|
||||
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
|
||||
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
|
||||
|
||||
def attachment_error(status, code, message, **fields):
|
||||
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
|
||||
headers={"Retry-After": "2"} if status == 429 else None)
|
||||
|
||||
|
||||
def stamp():
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
||||
@@ -69,7 +88,7 @@ async def reply(content):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
@@ -80,8 +99,21 @@ async def route(request):
|
||||
task.cancel()
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'transcript-comparison':
|
||||
transcript_comparison = True
|
||||
elif action == 'capability':
|
||||
attachment_capability = body.get('enabled', True)
|
||||
elif action == 'upload-error':
|
||||
next_upload_error = body
|
||||
elif action == 'blocked-proxy':
|
||||
blocked_proxy = body.get('enabled', True)
|
||||
elif action == 'attachment-history':
|
||||
selected = [attachments[key]['descriptor'] for key in body['ids']]
|
||||
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'reply':
|
||||
await emit(body.get('content', 'Late asynchronous fixture reply'))
|
||||
elif action == 'long-tool':
|
||||
@@ -102,10 +134,13 @@ async def route(request):
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
||||
if path == '/api/v1/auth/session':
|
||||
return web.json_response(dict(authenticated=True, mode='token'))
|
||||
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
|
||||
if path == '/api/v1/workspace-items':
|
||||
return web.json_response(dict(items=[], hasMore=False))
|
||||
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
|
||||
if path == '/api/v1/chats':
|
||||
return web.json_response(dict(chats=[]))
|
||||
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
|
||||
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
|
||||
return web.json_response(dict(chat=dict(comparison, messages=messages)))
|
||||
if path == '/api/v1/searches':
|
||||
return web.json_response(dict(searches=[]))
|
||||
if path == '/api/v1/models':
|
||||
@@ -115,8 +150,70 @@ async def route(request):
|
||||
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
|
||||
if request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(error='Fixture token missing'), status=401)
|
||||
if blocked_proxy:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
|
||||
if path == '/api/v1/agent':
|
||||
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
|
||||
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
return web.json_response(descriptor)
|
||||
if path == '/api/v1/agent/attachments' and request.method == 'POST':
|
||||
if not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
if next_upload_error:
|
||||
failure = next_upload_error; next_upload_error = None
|
||||
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
|
||||
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
|
||||
reader = await request.multipart()
|
||||
parts = {}
|
||||
while part := await reader.next():
|
||||
if part.name not in ('file', 'uploadId') or part.name in parts:
|
||||
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
|
||||
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
|
||||
if set(parts) != {'file', 'uploadId'}:
|
||||
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
|
||||
try:
|
||||
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
|
||||
filename = unicodedata.normalize('NFC', parts['file'][0])
|
||||
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
|
||||
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
|
||||
except (ValueError, AssertionError, TypeError):
|
||||
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
|
||||
_, mime, data = parts['file']
|
||||
mime = (mime or 'application/octet-stream').lower()
|
||||
if len(data) > CAPABILITY['maxFileBytes']:
|
||||
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
identity = (filename, mime, digest)
|
||||
if upload_id in uploads:
|
||||
old_identity, attachment_id = uploads[upload_id]
|
||||
if old_identity != identity:
|
||||
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
|
||||
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
|
||||
kind = 'file'
|
||||
if mime in CAPABILITY['imageMimeTypes']:
|
||||
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
|
||||
elif mime.startswith('text/'):
|
||||
try:
|
||||
if '\0' not in data.decode('utf-8-sig'):
|
||||
kind = 'text'
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
attachment_id = 'fixture-' + str(uuid.uuid4())
|
||||
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
|
||||
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
|
||||
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
|
||||
uploads[upload_id] = (identity, attachment_id)
|
||||
return web.json_response({'attachment': descriptor}, status=201)
|
||||
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
|
||||
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
|
||||
stored = attachments.get(attachment_id)
|
||||
if stored is None:
|
||||
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
|
||||
descriptor = stored['descriptor']
|
||||
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
|
||||
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
|
||||
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
|
||||
if path == '/api/v1/agent/read-state':
|
||||
if request.method == 'PUT':
|
||||
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
||||
@@ -125,13 +222,29 @@ async def route(request):
|
||||
if request.method == 'POST':
|
||||
body = await request.json()
|
||||
key, content = body['requestId'], body['content']
|
||||
refs = body.get('attachments', [])
|
||||
ids = [ref.get('id') for ref in refs]
|
||||
if refs and not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
|
||||
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
|
||||
if any(key not in attachments for key in ids):
|
||||
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
|
||||
if not content.strip() and not ids:
|
||||
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
|
||||
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
|
||||
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
|
||||
attempts[key] = attempts.get(key, 0) + 1
|
||||
if content == 'retry fixture' and attempts[key] == 1:
|
||||
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
|
||||
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
|
||||
if ids:
|
||||
receipt['attachmentIds'] = ids
|
||||
if key not in requests:
|
||||
requests[key] = receipt
|
||||
row = message(content, 'user', id=receipt['messageId'])
|
||||
requests[key] = dict(receipt, _payload=[content, ids])
|
||||
descriptors = [attachments[key]['descriptor'] for key in ids]
|
||||
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
|
||||
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
|
||||
busy = True
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
@@ -141,7 +254,7 @@ async def route(request):
|
||||
if content == 'echo wins fixture':
|
||||
await asyncio.sleep(0.3)
|
||||
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
|
||||
return web.json_response(requests[key], status=202)
|
||||
return web.json_response({k: v for k, v in requests[key].items() if k != '_payload'}, status=202)
|
||||
after = int(request.query.get('after', 0))
|
||||
rows = [m for m in messages if m['metadata']['entryId'] > after]
|
||||
limit = min(int(request.query.get('limit', 200)), 200)
|
||||
@@ -151,6 +264,9 @@ async def route(request):
|
||||
if request.query:
|
||||
return web.Response(status=400)
|
||||
socket = web.WebSocketResponse(); await socket.prepare(request)
|
||||
if blocked_proxy:
|
||||
await socket.close(code=4503, message=b'attachments_unavailable')
|
||||
return socket
|
||||
auth = await socket.receive_json(timeout=5)
|
||||
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
|
||||
await socket.close(code=4401); return socket
|
||||
@@ -172,6 +288,6 @@ async def route(request):
|
||||
|
||||
for i in range(3):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
app = web.Application()
|
||||
app = web.Application(client_max_size=8 * 1024 * 1024 + 64 * 1024)
|
||||
app.router.add_route('*', '/{path:.*}', route)
|
||||
web.run_app(app, host='127.0.0.1', port=8799)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
@MainActor
|
||||
final class AgentUITests: XCTestCase {
|
||||
@@ -10,7 +11,9 @@ final class AgentUITests: XCTestCase {
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
openAgent()
|
||||
try clearComposer()
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
@@ -23,6 +26,16 @@ final class AgentUITests: XCTestCase {
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
private func clearComposer() throws {
|
||||
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
|
||||
composer.tap(); composer.press(forDuration: 1)
|
||||
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
|
||||
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
|
||||
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
|
||||
let value = composer.value as? String ?? ""
|
||||
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
|
||||
}
|
||||
private func send(_ text: String) {
|
||||
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
|
||||
}
|
||||
@@ -37,6 +50,171 @@ final class AgentUITests: XCTestCase {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
|
||||
let boundary = "Fixture-" + UUID().uuidString
|
||||
let uploadID = UUID().uuidString
|
||||
var body = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
|
||||
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
|
||||
request.httpMethod = "POST"; request.httpBody = body
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
return try XCTUnwrap(json["attachment"] as? [String: Any])
|
||||
}
|
||||
|
||||
func testAttachmentHistoryDownloadAndReload() async throws {
|
||||
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
|
||||
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
|
||||
}
|
||||
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
|
||||
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
|
||||
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
|
||||
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
|
||||
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
|
||||
let path = try XCTUnwrap(file["workspacePath"] as? String)
|
||||
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
|
||||
let download = app.buttons["agent-download"].firstMatch
|
||||
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
|
||||
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
|
||||
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
|
||||
capture("agent-attachment-original-and-preview")
|
||||
try await control(["action": "long-tool"])
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
capture("agent-attachment-reloaded")
|
||||
// The real app's authenticated content request is recorded by the loopback fixture.
|
||||
let state = try await control(["action": "state"])
|
||||
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
|
||||
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
|
||||
}
|
||||
|
||||
func testAttachmentCapabilityAndPickerCancellation() async throws {
|
||||
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
|
||||
composer.tap(); composer.typeText("Preserved attachment draft caption")
|
||||
app.buttons["agent-attach"].tap()
|
||||
app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["Cancel"].firstMatch.tap()
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["Cancel"].firstMatch.tap()
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
capture("agent-attachment-pickers-cancelled")
|
||||
try await control(["action": "capability", "enabled": false])
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
try clearComposer()
|
||||
capture("agent-text-only-capability")
|
||||
}
|
||||
|
||||
func testPhotoSelectionToAttachmentOnlySend() async throws {
|
||||
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
|
||||
// Only synthetic assets are touched; no unrelated photo bytes are read.
|
||||
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
|
||||
// XCTest's synchronous UI waits must not block the authorization request.
|
||||
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
|
||||
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
|
||||
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
|
||||
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
|
||||
}
|
||||
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
||||
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
|
||||
// Recover only fixtures from an interrupted test, identified by BOTH our unique
|
||||
// date and original-filename prefix, before creating this run's asset.
|
||||
let options = PHFetchOptions()
|
||||
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
|
||||
let previous = PHAsset.fetchAssets(with: .image, options: options)
|
||||
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
|
||||
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
|
||||
}.map(\.localIdentifier)
|
||||
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
|
||||
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
|
||||
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
|
||||
}
|
||||
try XCTUnwrap(photo.pngData()).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
nonisolated(unsafe) var assetID: String?
|
||||
try await PHPhotoLibrary.shared().performChanges { @Sendable in
|
||||
let creation = PHAssetCreationRequest.forAsset()
|
||||
// A unique synthetic date lets us find this asset without enumerating other photos.
|
||||
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
|
||||
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
|
||||
creation.addResource(with: .photo, fileURL: url, options: options)
|
||||
assetID = creation.placeholderForCreatedAsset?.localIdentifier
|
||||
}
|
||||
let ownedID = try XCTUnwrap(assetID)
|
||||
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
|
||||
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
|
||||
do { try await exercisePhotoSelection() }
|
||||
catch {
|
||||
try await removeSyntheticPhotos([ownedID])
|
||||
throw error
|
||||
}
|
||||
try await removeSyntheticPhotos([ownedID])
|
||||
}
|
||||
|
||||
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
|
||||
try await Self.eraseSyntheticPhotos(identifiers)
|
||||
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
|
||||
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
|
||||
}
|
||||
|
||||
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
|
||||
try await Task.detached {
|
||||
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
|
||||
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
|
||||
XCTAssertEqual(remaining, 0)
|
||||
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
|
||||
}.value
|
||||
}
|
||||
|
||||
private func exercisePhotoSelection() async throws {
|
||||
app.activate()
|
||||
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
|
||||
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
|
||||
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 8))
|
||||
cell.tap()
|
||||
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
|
||||
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
|
||||
await fulfillment(of: [ready], timeout: 15)
|
||||
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
|
||||
capture("agent-photo-upload-ready")
|
||||
app.buttons["agent-send"].tap()
|
||||
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
|
||||
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
let state = try await control(["action": "state"])
|
||||
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
let submission = try XCTUnwrap(requests.values.first)
|
||||
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
|
||||
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
|
||||
capture("agent-photo-attachment-only-canonical")
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
capture("agent-photo-attachment-only-reloaded")
|
||||
}
|
||||
|
||||
func testSendQueueToolsAndRetry() async throws {
|
||||
capture("agent-history")
|
||||
send("first fixture")
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
/// Loopback-only regressions. No picker selection or external backend writes.
|
||||
@MainActor
|
||||
final class TranscriptFadeUITests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
try await control(["action": "reset", "count": 160])
|
||||
try await control(["action": "transcript-comparison"])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
|
||||
private func showSidebar() {
|
||||
if !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
private func openAgent() {
|
||||
showSidebar()
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists)
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8))
|
||||
}
|
||||
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
|
||||
private var transcript: XCUIElement {
|
||||
// The tablet sidebar is also a scroll view; select the detail by its content.
|
||||
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
|
||||
}
|
||||
|
||||
private func capture(_ name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
|
||||
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
|
||||
func testSharedHeaderFadeGeometry() async throws {
|
||||
showSidebar()
|
||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
|
||||
// Normal chat is lazy and can open at either end on phone/tablet.
|
||||
let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch
|
||||
XCTAssertTrue(normal.waitForExistence(timeout: 8))
|
||||
let normalTop = normal.frame.minY
|
||||
let first = app.staticTexts["History message 1: local simulator fixture."]
|
||||
if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) }
|
||||
else { normal.swipeDown(velocity: .slow) }
|
||||
capture("normal-chat-content-crossing-shared-fade")
|
||||
XCTAssertTrue(app.buttons["Attach file"].isHittable)
|
||||
|
||||
openAgent()
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(scroll.exists)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
||||
capture("agent-content-crossing-shared-fade")
|
||||
XCTAssertTrue(composer.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-send"].exists)
|
||||
// A viewport padded below the header clips bubbles before they reach its fade.
|
||||
XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1,
|
||||
"Agent must scroll beneath the same workspace header as normal chat")
|
||||
}
|
||||
|
||||
func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws {
|
||||
openAgent()
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
||||
// Stay below the header's content clearance, where text is readable.
|
||||
let visibleTop = scroll.frame.minY + 100
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
||||
$0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY
|
||||
})
|
||||
let before = anchor.frame.minY
|
||||
let stateBefore = try await control(["action": "state"])
|
||||
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
|
||||
await fulfillment(of: [unread], timeout: 8)
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
let stateAfter = try await control(["action": "state"])
|
||||
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
||||
XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int))
|
||||
capture("agent-fade-reading-position-and-unread")
|
||||
|
||||
for _ in 0..<30 {
|
||||
if app.buttons["Load earlier messages"].isHittable { break }
|
||||
scroll.swipeDown(velocity: .fast)
|
||||
}
|
||||
let load = app.buttons["Load earlier messages"]
|
||||
XCTAssertTrue(load.isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
load.tap()
|
||||
capture("agent-fade-prepend-reading-position")
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
||||
"Prepending must preserve the readable row's position beneath the header")
|
||||
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||
await fulfillment(of: [followed], timeout: 5)
|
||||
composer.tap()
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
capture("agent-fade-latest-with-keyboard")
|
||||
// No send: this only changes a local draft, then clears it.
|
||||
composer.typeText("Fade draft\nsecond line\nthird line")
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count))
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user