"페이지도 안 열리고, 델리게이트 콜백도 안 오고, 에러도 없었어. 웹 뷰가 아예 없었거든."
다리 둘, 방향 둘
NSHostingView/NSHostingController는 SwiftUI 뷰를 AppKit 안에 넣어. 호스팅 뷰의 수명은 다른NSView와 똑같이 AppKit 쪽이 관리해. 이 방향으론 놀랄 일이 거의 없어.NSViewRepresentable은 AppKit 뷰를 SwiftUI 안에 넣어. 뷰 트리에 처음 나타날 때 SwiftUI가makeNSView를 부르고, 입력이 바뀔 때마다updateNSView를 불러. 트리에서 빠지면 뷰를 부숴. 갱신이 여러 번 일어나도 살아남아야 하는 델리게이트 상태는Coordinator가 들고 있어.
두 번째 방향엔 까먹기 쉬운 결과가 하나 있어. AppKit 객체의 수명을 SwiftUI 뷰 트리가 쥔다는 거야. representable이 if 안에 있으면, 조건이 거짓인 동안 그 객체는 존재하지 않아.
한 번도 안 만들어진 미리보기 창
Mac 에이전트 클라이언트의 도크에 웹 미리보기 창이 있었어. 화면 없는 스모크 테스트가 그 창의 모델을 조종했지. 작업 공간을 연결하고, 도구를 고르고, openWorkspace(relativePath:)를 부르고, 페이지가 열리길 기다렸어. 영원히 기다렸어. 그런데 그 창 앞에는 뷰 수준의 관문이 있었어. 세션이 연결되지 않았으면 안내 화면을 대신 보여주는 관문이야. 게다가 WKWebView가 만들어지는 유일한 곳이 그 창의 makeNSView 안이었어. 거기서 뷰를 모델에 넘겨주기도 했고. 창이 안 그려지니 모델의 weak var webView는 계속 nil이었고, webView?.load(…)는 에러 하나 없이 아무것도 안 하고 넘어갔어.
고친 건 두 가지고, 둘 다 필요했어. 첫째, 스모크 테스트가 뷰를 건너뛰고 모델을 직접 찌르는 대신, 사람처럼 세션을 열어서 진짜 관문을 통과해. 둘째, 페이지를 이동하기 전에 명시적으로 isAttached를 기다리고, 시간 안에 안 되면 무엇이 안 됐는지 이름을 대며 실패하는 데드라인을 걸어. 쓸모 있는 구별법도 하나 나왔어. 같은 스모크 테스트를 접속할 수 없는 URL로 돌려봐. 웹 뷰가 있으면 1초 안에 이동 실패 콜백이 오고, 아무 반응이 없으면 웹 뷰가 없다는 증거야.
관문은 모델이 판단해야 해
같은 도크에 비슷한 버그가 하나 더 숨어 있었어. 파일 창이랑 터미널 창은 모델의 로컬 루트가 nil이면 "먼저 세션을 시작해"를 보여줬는데, 원격 세션은 원래 그 필드를 nil로 둬. 그래서 Mac에서 원격 도크가 한 번도 안 그려졌는데, 모델 수준 테스트는 전부 통과했어. 고친 방법은 이래. 준비됐는지 판단하는 기준을 모델에 딱 하나만 뒀어(로컬 루트 또는 원격 세션). 그리고 안내 화면을 보여줄지는 뷰가 모델의 static 함수를 불러서 정하게 했어. 이제 그 결정은 함수 안에 있어서, 테스트는 그걸 부를 수 있고 뷰 본문은 우회할 수 없어.