import SwiftUI import Kingfisher enum LanguageOption: String { case system, ko, en, ja } func recordHarnessEvent(_ event: String) { let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let url = directory.appendingPathComponent("events.jsonl") let data = try! JSONSerialization.data(withJSONObject: [ "event": event, "time": CACurrentMediaTime(), "pid": ProcessInfo.processInfo.processIdentifier, "test": ProcessInfo.processInfo.environment["SB_TEST_NAME"] ?? "manual" ]) + Data([10]) if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } let handle = try! FileHandle(forWritingTo: url) handle.seekToEndOfFile() handle.write(data) try! handle.close() } final class FixtureProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) } override func stopLoading() {} } @MainActor final class FrameRecorder { static let shared = FrameRecorder() private var link: CADisplayLink? private var end: CFTimeInterval = 0 private var directory: URL? private var frames: [[String: Any]] = [] private var onTransition: (() -> Void)? func start(_ name: String, duration: TimeInterval = 2.5, onTransition: (() -> Void)? = nil) { stop() self.onTransition = onTransition recordHarnessEvent("record-\(name)") let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] directory = documents.appendingPathComponent(name) try! FileManager.default.createDirectory(at: directory!, withIntermediateDirectories: true) frames = [] end = CACurrentMediaTime() + duration let displayLink = CADisplayLink(target: self, selector: #selector(capture)) displayLink.preferredFramesPerSecond = 30 displayLink.add(to: .main, forMode: .common) link = displayLink } @objc private func capture() { guard CACurrentMediaTime() < end else { stop(); return } guard let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) .flatMap(\.windows).first(where: \.isKeyWindow), let directory else { return } let format = UIGraphicsImageRendererFormat() format.scale = 1 let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) } let filename = String(format: "%04d.png", frames.count) try! image.pngData()!.write(to: directory.appendingPathComponent(filename)) frames.append(["file": filename, "time": CACurrentMediaTime()]) try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted) .write(to: directory.appendingPathComponent("frames.json")) if onTransition != nil, hasBothEndpointImages(image) { recordHarnessEvent("observed-mixed-\(directory.lastPathComponent)") let action = onTransition onTransition = nil DispatchQueue.main.async { action?() } } } private func hasBothEndpointImages(_ image: UIImage) -> Bool { let row = image.cgImage!.cropping(to: CGRect(x: 0, y: 240, width: 375, height: 1))! var pixels = [UInt8](repeating: 0, count: 375 * 4) pixels.withUnsafeMutableBytes { buffer in let context = CGContext(data: buffer.baseAddress, width: 375, height: 1, bitsPerComponent: 8, bytesPerRow: 375 * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! context.draw(row, in: CGRect(x: 0, y: 0, width: 375, height: 1)) } var red = 0 var green = 0 for offset in stride(from: 0, to: pixels.count, by: 4) { if pixels[offset] > 200, pixels[offset + 1] < 100, pixels[offset + 2] < 100 { red += 1 } if pixels[offset] < 100, pixels[offset + 1] > 150, pixels[offset + 2] < 150 { green += 1 } } return red > 5 && green > 5 } private func stop() { link?.invalidate() link = nil if let directory { try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted) .write(to: directory.appendingPathComponent("frames.json")) } } } @main struct SubBannerGestureApp: App { init() { recordHarnessEvent("launch") let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [FixtureProtocol.self] ImageDownloader.default.sessionConfiguration = configuration for index in 1...3 { for mixed in [false, true] { let size = CGSize(width: 600, height: mixed && index == 2 ? 200 : 300) let image = UIGraphicsImageRenderer(size: size).image { context in [UIColor.systemRed, .systemGreen, .systemBlue][index - 1].setFill() context.fill(CGRect(origin: .zero, size: size)) UIColor.white.setFill() context.fill(CGRect(x: 18, y: 0, width: 12, height: size.height)) context.fill(CGRect(x: size.width - 30, y: 0, width: 12, height: size.height)) ("BANNER \(index)" as NSString).draw(at: CGPoint(x: 180, y: 80), withAttributes: [ .font: UIFont.boldSystemFont(ofSize: 36), .foregroundColor: UIColor.white ]) } ImageCache.default.store(image, forKey: Self.url(index, mixed: mixed), toDisk: false) } } } static func url(_ index: Int, mixed: Bool) -> String { "https://gesture-fixture.invalid/\(mixed ? "mixed" : "equal")/\(index).png" } var body: some Scene { WindowGroup { GestureHarness() } } } struct GestureHarness: View { private let arguments = ProcessInfo.processInfo.arguments @Environment(\.scenePhase) private var scenePhase @State private var count = 0 @State private var lastID = 0 @State private var replaced = false @State private var shown = true private var active: Bool { arguments.contains("active") } private var items: [SubBanner] { if arguments.contains("empty") { return [] } let amount = replaced || arguments.contains("single") ? 1 : (active ? 2 : 3) return (1...amount).map { index in SubBanner(id: replaced ? 9 : index, placement: .homeRecommendation, imageUrl: SubBannerGestureApp.url(index, mixed: arguments.contains("mixed")), webUrl: "https://gesture-fixture.invalid/link/\(index)", androidDeepLink: nil, iosDeepLink: nil) } } var body: some View { VStack(spacing: SodaSpacing.s8) { Text("taps=\(count);last=\(lastID)").accessibilityIdentifier("tap-status") Text("Native touch regression") HStack { Button("Record") { FrameRecorder.shared.start("manual") } Button("Forward") { FrameRecorder.shared.start("forward") } Button("Replace") { recordHarnessEvent("replace"); replaced = true } Button(shown ? "Hide" : "Show") { recordHarnessEvent(shown ? "hide" : "show"); shown.toggle() } } ScrollView { VStack(spacing: SodaSpacing.s24) { if shown { SubBannerCarousel(items: items) { item in count += 1 lastID = item.id recordHarnessEvent("tap-\(item.id)") } .environment(\.scenePhase, active ? scenePhase : .inactive) } Text("추천 콘텐츠").accessibilityIdentifier("after-banner") ForEach(0..<30) { index in Text("Content row \(index)").frame(maxWidth: .infinity).frame(height: 44) } } } } .appFont(.body3) .background(Color.bg) .foregroundColor(.white) .preferredColorScheme(.dark) .task(id: scenePhase) { recordHarnessEvent("phase-\(scenePhase)") guard active, scenePhase == .active, !arguments.contains("background") else { return } do { try await Task.sleep(nanoseconds: 8_000_000_000) } catch { return } let name = arguments.contains("replace-during-wrap") ? "replace" : (arguments.contains("hide-during-wrap") ? "hide" : "automatic") if name == "automatic" { FrameRecorder.shared.start(name, duration: 4) } else { FrameRecorder.shared.start(name, duration: 4) { recordHarnessEvent(name) if name == "replace" { replaced = true } else { shown = false } } } } } }