diff --git a/AGENTS.md b/AGENTS.md index ee1c818a3..f040e81fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Xcode project](#generating-the-xcode-project)). On a fresh machine, run `./ide generating. Plain `./ide` fails fast and points at bootstrap. The executables in the repo root are the dev scripts. They are `ide`, `test`, -`swiftformat`, `sf-symbols`, `sync-agents`, `profile`, `icons`, `flaky`, `simulator`, +`swiftformat`, `sf-symbols`, `sync-agents`, `profile`, `flyover`, `icons`, `flaky`, `simulator`, `worktree`, `xcstrings`, `attribution`, `shellcheck`, `codex-watchdog`, `tla-check`, `circleci-artifacts`, `snapshot-shards`, `loc`. Each takes `--help`. Use one of these scripts instead of hand-rolling its job. `./test` owns iOS tests; the native-macOS Ledger scheme is the exception @@ -565,10 +565,11 @@ generates the project or starts a simulator. the applicable checks. - **Multi-step work lands one commit per step**, so history stays bisectable and can land piecewise — including pure-groundwork steps, which say so in the body. -- **Commit completed work eagerly.** Once a coherent change is verified, commit - it without waiting for a separate request. Never hand back a finished task - with task-related changes left local, unpushed, or uncommitted. Honor an - explicit request to keep work uncommitted. +- **Commit and push completed work eagerly.** Once a coherent change is + verified, commit it and push the current feature branch. Do not wait for a + separate request. Never hand back a finished task with task-related changes + left local, unpushed, or uncommitted. Honor an explicit request to keep work + local or uncommitted. ### GitHub diff --git a/README.md b/README.md index 435a75936..33bbfe66b 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ sync-agents Sync AGENTS.md → CLAUDE.md and .claude/skills/ simulator Resolve/create this checkout's simulator, boot it, print its UDID worktree Check or safely fast-forward a checkout against origin/main profile Report build/test hot spots (see `./profile --help`) +flyover Export and preview Where's native Flyover catalog as a static QA atlas flaky Detect flaky tests, update FLAKY_TESTS.md (see `./flaky --help`) circleci-artifacts Download every artifact for a CircleCI job snapshot-shards Validate and rebalance snapshot suite assignments diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index b46d4dc0b..a457dd34b 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -7,6 +7,8 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format ## Scope & dependencies - **Flyover may import SwiftUI, SFSafeSymbols, BroadwayCore/BroadwayUI, and SnapshotKit.** It must not import WhereCore, WhereUI, persistence frameworks, or any app module. +- **Keep the static exporter generic over `ScreenID`.** Accept the hosted PNG operation as a closure. Never import SnapshotKitTesting. +- **Keep the web shell under [`Web/`](Web).** Do not make it an app-bundle resource or add remote assets. - **Apps own their typed screen IDs, demo/synthetic state, catalog construction, and the DEBUG-only entry point** that hosts ``FlyoverView``. - **Use English literals for strings** in this developer-only shared tool. An app localizes the entry point it adds to its own UI. @@ -26,6 +28,12 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format - **Keep global traits session-only.** Apply them to registered content, not Flyover chrome. - **Register forward push/modal routes only.** Flyover derives Back/Dismiss cues from incoming routes. - **Type erase only at the heterogeneous content/control registry boundary.** +- **Validate every stable screen and variant identifier before capture.** Use generated ordinals for image paths. +- **Serve only a validated generated artifact.** Bind to loopback unless the user selects LAN access. +- **Load a thumbnail for every visible web screen.** Reserve full-resolution captures for the inspector and raw PNG link. +- **Apply web residency limits only to offscreen preload candidates.** +- **Preserve snapshot-backed capture intent.** Reject mixed sizing matrices unless the app supplies an explicit export policy. +- **Fail full-content export when sizing does not converge.** Never publish a viewport fallback. ## Testing diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 47420a874..575a628f8 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -134,6 +134,138 @@ Then keep their central catalog limited to grouping and assembly. Swift macros cannot discover all conformers or navigation destinations across a module. A generated source scan would add build ordering and cache invalidation complexity. +## Static web export + +`FlyoverWebExporter` converts a DEBUG catalog into a static QA atlas. It writes +native PNG captures and card-size thumbnails. It also writes `manifest.json` +and `manifest.js`. The exporter derives each thumbnail from its hosted native +capture. It does not render the SwiftUI content again. The web shell reads +`manifest.js`, so the atlas works from `file://` and any static host. The +browser changes images and navigation state. It does not run SwiftUI or +serialize `FlyoverControl` actions. + +A thumbnail uses at most 1,024 pixels on its longest axis. A full-content +thumbnail shows the top device viewport. The inspector and raw PNG link use the +complete capture. + +The exporter validates the complete plan before its first capture. The host +provides one stable string for each typed screen ID and one capture closure. +Stable IDs must be nonempty and unique. Variant IDs must also be nonempty and +unique within a screen. Image paths use generated ordinals, never these IDs. + +Hosted variants have a `FlyoverExportPolicy` with a fixed viewport by default. +Snapshot-backed variants inherit their settle, readiness, and hook behavior. +Their frame matrix must resolve to one capture extent: fixed, intrinsic, +full-content, or two-axis full-content. A mixed matrix has no resolved policy. +The app must supply an explicit policy before export. + +Profiles are additive and keep request order. No profile matrix is generated. +The built-in IDs are: + +- `phone-light`, `phone-dark`, `tablet-light`, and `phone-landscape` +- `phone-small`, `phone-xxxl`, and `phone-ax3` +- `phone-contrast`, `phone-rtl`, `phone-bold`, and `phone-voiceover` + +The first profile is the initial web selection. An empty profile list becomes +`phone-light` followed by `phone-dark`. Fixed Flyover viewports keep their size +while profile traits still apply. Tablet and landscape profiles also apply an +explicit interface idiom and size classes to adaptive content. + +Run Where's exporter from the repository root: + +```sh +./flyover export +./flyover export --profile phone-light --profile phone-dark +./flyover export --output /tmp/where-flyover --profile tablet-light +``` + +The default output is `.build/flyover/where`, resolved from the caller's +directory. The command stages the complete site and replaces only an existing +directory marked with `.flyover-generated`. A failed capture leaves the last +successful atlas unchanged. Before replacement, the command validates all +schema fields, references, image mappings, and generated PNG files. A repeated +in-repository export excludes its prior generated directory from dirty-build +metadata. A Git status error stops the export. + +### Preview the export + +Serve the default export on this computer: + +```sh +./flyover preview +``` + +The command selects a free port and prints the local URL. Press Control-C to +stop the server. + +Use `--lan` to open the preview to other devices on the local network: + +```sh +./flyover preview --lan +./flyover preview --output /tmp/where-flyover --lan --port 8080 +``` + +The command prints one URL for each network address that it finds. The other +device must be able to reach this computer. A macOS firewall prompt can appear. +The server pins the validated directory for its lifetime. It does not follow a +symbolic link that replaces an allowed file or directory after startup. + +WARNING: LAN preview has no authentication or TLS. Any device that can reach +the computer can view the native screenshots. Stop and restart the preview +after each export. + +For a static host, upload the contents of the generated directory. Put +`index.html` at the selected host root or subpath. The site needs no build step. +All site URLs are relative. + +The manifest compatibility boundary is `schemaVersion: 1`. It contains the +application and build identity, profiles, precomputed canvas geometry, groups, +screens, routes, and image metadata. It contains no local source or account +paths. New image records include the optional thumbnail path and pixel size. +Older schema-1 artifacts remain readable. The web shell uses the full capture +when thumbnail metadata is absent. Full-content sizing uses SnapshotKitTesting +limits and convergence rules. A sizing error stops the export. The exporter +never substitutes a viewport image. + +The website opens the first catalog group in canvas mode. A floating control +dock keeps the canvas visible. The group panel and overview map move between +groups without recalculating the graph. The canvas keeps its position when a +state, profile, or panel changes. + +Canvas and list views give an active thumbnail source to every visible screen. +The list can also preload nearby thumbnails. This preload targets six active +images and 24 million thumbnail pixels. Visible screens override both targets. +The inspector removes these sources while it shows one full-resolution capture. + +Point to or focus a card to emphasize its connected routes. The site dims +unrelated cards and routes until the focus moves. Filters for groups, capture +extents, and route states stay in a separate panel. + +Search opens a command palette. It matches group, screen, state, and connected +route names. A result opens its screen or fits its group. List mode shows the +same selection in grouped rows. State and profile changes update the native +image without changing the selected screen. + +The inspector uses the full browser window. The image stays central while a +drawer supplies capture data and route links. Full-content images use a +device-width scroll area. The Fit and 100% controls change the image scale +without changing the capture. + +Atlas controls, labels, and screenshots do not support browser text selection +or image dragging. Search text, screen titles, error details, and metadata +values remain selectable. + +The browser hash stores the view, screen, state, and profile. Browser Back and +Forward restore these values. The site also supplies these keyboard controls: + +- Press `/` or `Command-K` to open search. +- Press `F` to fit the complete canvas. +- Press `0` to fit the current group. +- Press `+` or `-` to change the canvas zoom. +- Press an arrow key, `[` or `]`, to move between inspector screens. +- Press `I` to show or hide the inspector details. +- Press Escape to close the inspector. + ## Testing Run unit coverage with: diff --git a/Shared/Flyover/Sources/FlyoverCaptureExtent.swift b/Shared/Flyover/Sources/FlyoverCaptureExtent.swift new file mode 100644 index 000000000..c40eb9683 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCaptureExtent.swift @@ -0,0 +1,9 @@ +#if DEBUG + /// The amount of a registered screen that a web export captures. + public enum FlyoverCaptureExtent: String, Codable, CaseIterable, Sendable { + case viewport + case intrinsic + case fullContent + case fullContent2D + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverCaptureProfile.swift b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift new file mode 100644 index 000000000..c29322284 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift @@ -0,0 +1,191 @@ +#if DEBUG + import CoreGraphics + import SnapshotKit + import SwiftUI + + /// One additive device and accessibility profile for a static export. + public enum FlyoverCaptureProfile: String, CaseIterable, Codable, Identifiable, Sendable { + case phoneLight = "phone-light" + case phoneDark = "phone-dark" + case tabletLight = "tablet-light" + case phoneLandscape = "phone-landscape" + case phoneSmall = "phone-small" + case phoneXXXL = "phone-xxxl" + case phoneAX3 = "phone-ax3" + case phoneContrast = "phone-contrast" + case phoneRTL = "phone-rtl" + case phoneBold = "phone-bold" + case phoneVoiceOver = "phone-voiceover" + + public var id: String { + rawValue + } + + public var title: String { + switch self { + case .phoneLight: "Phone Light" + case .phoneDark: "Phone Dark" + case .tabletLight: "Tablet Light" + case .phoneLandscape: "Phone Landscape" + case .phoneSmall: "Phone Small Text" + case .phoneXXXL: "Phone XXXL Text" + case .phoneAX3: "Phone Accessibility 3" + case .phoneContrast: "Phone Increased Contrast" + case .phoneRTL: "Phone Right to Left" + case .phoneBold: "Phone Bold Text" + case .phoneVoiceOver: "Phone VoiceOver" + } + } + + public static func parse(_ identifiers: [String]) throws -> [Self] { + try orderedUnique(identifiers.map { identifier in + guard let profile = Self(rawValue: identifier) else { + throw FlyoverExportError.unknownProfile(identifier) + } + return profile + }) + } + + static func orderedUnique(_ requestedProfiles: [Self]) -> [Self] { + let profiles = requestedProfiles.isEmpty ? [.phoneLight, .phoneDark] : requestedProfiles + var seen: Set = [] + return profiles.filter { seen.insert($0).inserted } + } + + var deviceName: String { + self == .tabletLight ? "tablet" : "phone" + } + + var orientationName: String { + self == .phoneLandscape ? "landscape" : "portrait" + } + + var colorSchemeName: String { + colorScheme == .dark ? "dark" : "light" + } + + var dynamicTypeName: String { + switch self { + case .phoneSmall: + "small" + case .phoneXXXL: + "xxxl" + case .phoneAX3: + "accessibility3" + case .phoneLight, .phoneDark, .tabletLight, .phoneLandscape, + .phoneContrast, .phoneRTL, .phoneBold, .phoneVoiceOver: + "large" + } + } + + var contrastName: String { + contrast == .increased ? "increased" : "standard" + } + + var layoutDirectionName: String { + layoutDirection == .rightToLeft ? "right-to-left" : "left-to-right" + } + + var legibilityWeightName: String { + legibilityWeight == .bold ? "bold" : "regular" + } + + var snapshotTypeName: String { + snapshotType == .accessibility ? "accessibility" : "standard" + } + + func configuration( + viewport: FlyoverViewport, + captureExtent: FlyoverCaptureExtent, + ) -> SnapshotConfiguration { + let baseSize = switch viewport { + case .device: profileSize + case let .fixed(size): size + } + let frame = switch captureExtent { + case .viewport: + SnapshotConfiguration.Frame(name: rawValue, size: .fixed(baseSize)) + case .intrinsic: + SnapshotConfiguration.Frame( + name: rawValue, + size: .intrinsic(maxWidth: baseSize.width), + ) + case .fullContent: + SnapshotConfiguration.Frame.fullContent( + name: rawValue, + width: baseSize.width, + minimumHeight: baseSize.height, + ) + case .fullContent2D: + SnapshotConfiguration.Frame.fullContent2D( + name: rawValue, + minimumSize: baseSize, + ) + } + return SnapshotConfiguration( + colorScheme: colorScheme, + dynamicType: dynamicType, + contrast: contrast, + layoutDirection: layoutDirection, + legibilityWeight: legibilityWeight, + layoutTraits: layoutTraits, + device: frame, + snapshotType: snapshotType, + ) + } + + private var profileSize: CGSize { + switch self { + case .tabletLight: + CGSize(width: 834, height: 1194) + case .phoneLandscape: + CGSize(width: 874, height: 402) + case .phoneLight, .phoneDark, .phoneSmall, .phoneXXXL, + .phoneAX3, .phoneContrast, .phoneRTL, .phoneBold, + .phoneVoiceOver: + CGSize(width: 402, height: 874) + } + } + + private var colorScheme: ColorScheme { + self == .phoneDark ? .dark : .light + } + + private var layoutTraits: SnapshotConfiguration.LayoutTraits { + switch self { + case .tabletLight: .tabletPortrait + case .phoneLandscape: .phoneLandscape + case .phoneLight, .phoneDark, .phoneSmall, .phoneXXXL, + .phoneAX3, .phoneContrast, .phoneRTL, .phoneBold, + .phoneVoiceOver: .phonePortrait + } + } + + private var dynamicType: DynamicTypeSize { + switch self { + case .phoneSmall: .small + case .phoneXXXL: .xxxLarge + case .phoneAX3: .accessibility3 + case .phoneLight, .phoneDark, .tabletLight, .phoneLandscape, + .phoneContrast, .phoneRTL, .phoneBold, .phoneVoiceOver: + .large + } + } + + private var contrast: ColorSchemeContrast { + self == .phoneContrast ? .increased : .standard + } + + private var layoutDirection: LayoutDirection { + self == .phoneRTL ? .rightToLeft : .leftToRight + } + + private var legibilityWeight: LegibilityWeight { + self == .phoneBold ? .bold : .regular + } + + private var snapshotType: SnapshotConfiguration.SnapshotType { + self == .phoneVoiceOver ? .accessibility : .standard + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverCaptureRequest.swift b/Shared/Flyover/Sources/FlyoverCaptureRequest.swift new file mode 100644 index 000000000..ff32c1312 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCaptureRequest.swift @@ -0,0 +1,51 @@ +#if DEBUG + import SnapshotKit + import SwiftUI + + /// One fully resolved image request in a web export plan. + @MainActor + public struct FlyoverCaptureRequest { + public let groupTitle: String + public let screenID: String + public let screenTitle: String + public let variantID: String + public let variantTitle: String + public let profile: FlyoverCaptureProfile + public let configuration: SnapshotConfiguration + public let captureExtent: FlyoverCaptureExtent + public let measurementReadiness: SnapshotMeasurementReadiness + public let settle: SnapshotSettle + public let onReadyToMeasure: (@MainActor () async -> Void)? + public let onReadyToSnapshot: (@MainActor () async -> Void)? + public let captureName: String + public let content: AnyView + + init( + groupTitle: String, + screenID: String, + screenTitle: String, + variantID: String, + variantTitle: String, + profile: FlyoverCaptureProfile, + configuration: SnapshotConfiguration, + policy: FlyoverExportPolicy, + captureName: String, + content: AnyView, + ) { + self.groupTitle = groupTitle + self.screenID = screenID + self.screenTitle = screenTitle + self.variantID = variantID + self.variantTitle = variantTitle + self.profile = profile + self.configuration = configuration + captureExtent = policy.captureExtent + measurementReadiness = policy.measurementReadiness + settle = policy.settle + onReadyToMeasure = policy.onReadyToMeasure + onReadyToSnapshot = policy.onReadyToSnapshot + self.captureName = captureName + self.content = content + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverCapturedImage.swift b/Shared/Flyover/Sources/FlyoverCapturedImage.swift new file mode 100644 index 000000000..c2e6e958d --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCapturedImage.swift @@ -0,0 +1,24 @@ +#if DEBUG + import CoreGraphics + import Foundation + + /// PNG bytes and dimensions returned by a hosted capture closure. + public struct FlyoverCapturedImage: Sendable { + public let pngData: Data + public let pointSize: CGSize + public let pixelSize: CGSize + public let scale: CGFloat + + public init( + pngData: Data, + pointSize: CGSize, + pixelSize: CGSize, + scale: CGFloat, + ) { + self.pngData = pngData + self.pointSize = pointSize + self.pixelSize = pixelSize + self.scale = scale + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportBuild.swift b/Shared/Flyover/Sources/FlyoverExportBuild.swift new file mode 100644 index 000000000..b10aca5ff --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportBuild.swift @@ -0,0 +1,30 @@ +#if DEBUG + /// Source and simulator metadata recorded with an export. + public struct FlyoverExportBuild: Codable, Equatable, Sendable { + public let commit: String + public let dirty: Bool + public let branch: String? + public let generatedAt: String + public let xcodeVersion: String + public let simulatorDevice: String + public let simulatorOS: String + + public init( + commit: String, + dirty: Bool, + branch: String?, + generatedAt: String, + xcodeVersion: String, + simulatorDevice: String, + simulatorOS: String, + ) { + self.commit = commit + self.dirty = dirty + self.branch = branch + self.generatedAt = generatedAt + self.xcodeVersion = xcodeVersion + self.simulatorDevice = simulatorDevice + self.simulatorOS = simulatorOS + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportContent.swift b/Shared/Flyover/Sources/FlyoverExportContent.swift new file mode 100644 index 000000000..254e65a17 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportContent.swift @@ -0,0 +1,23 @@ +#if DEBUG + import SwiftUI + + /// The native registered content that the web exporter sends to the host. + struct FlyoverExportContent: View { + let navigationContainer: FlyoverNavigationContainer + let content: AnyView + + var body: some View { + Group { + switch navigationContainer { + case .stack: + NavigationStack { + content + } + case .none: + content + } + } + .allowsHitTesting(false) + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportError.swift b/Shared/Flyover/Sources/FlyoverExportError.swift new file mode 100644 index 000000000..e456aadcf --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportError.swift @@ -0,0 +1,63 @@ +#if DEBUG + import Foundation + + /// A structural, capture, or artifact error from a static export. + public enum FlyoverExportError: Error, Equatable, Sendable { + case invalidCatalog(issueCount: Int) + case emptyApplicationIdentifier + case emptyScreenIdentifier(screenTitle: String) + case duplicateScreenIdentifier(String) + case emptyVariantIdentifier(screen: String) + case duplicateVariantIdentifier(screen: String, variant: String) + case mixedSizingPolicy(screen: String, variant: String, extents: [String]) + case measurementHookRequiresMeasuredSizing(screen: String, variant: String) + case missingManifestGeometry(kind: String, identifier: String) + case unknownProfile(String) + case captureFailed( + group: String, + screen: String, + variant: String, + profile: String, + phase: String, + reason: String, + ) + case emptyPNG(screen: String, variant: String, profile: String) + case assetCountMismatch(expected: Int, actual: Int) + case outputWriteFailed(path: String, reason: String) + } + + extension FlyoverExportError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .invalidCatalog(issueCount): + "The Flyover catalog has \(issueCount) validation errors." + case .emptyApplicationIdentifier: + "The export application identifier is empty." + case let .emptyScreenIdentifier(screenTitle): + "The export identifier for \(screenTitle) is empty." + case let .duplicateScreenIdentifier(identifier): + "The screen export identifier \(identifier) is not unique." + case let .emptyVariantIdentifier(screen): + "A variant export identifier is empty in \(screen)." + case let .duplicateVariantIdentifier(screen, variant): + "The variant export identifier \(variant) is not unique in \(screen)." + case let .mixedSizingPolicy(screen, variant, extents): + "The export policy for \(screen) / \(variant) mixes sizing classes: \(extents.joined(separator: ", "))." + case let .measurementHookRequiresMeasuredSizing(screen, variant): + "The export policy for \(screen) / \(variant) declares onReadyToMeasure, but viewport sizing has no measured-content phase." + case let .missingManifestGeometry(kind, identifier): + "The Flyover layout has no \(kind) geometry for \(identifier)." + case let .unknownProfile(identifier): + "The Flyover capture profile \(identifier) is unknown." + case let .captureFailed(group, screen, variant, profile, phase, reason): + "The Flyover export failed during \(phase) for \(group) / \(screen) / \(variant) / \(profile): \(reason)" + case let .emptyPNG(screen, variant, profile): + "The capture for \(screen) / \(variant) / \(profile) returned an empty PNG." + case let .assetCountMismatch(expected, actual): + "The Flyover artifact contains \(actual) images. The manifest requires \(expected)." + case let .outputWriteFailed(path, reason): + "The Flyover exporter could not write \(path): \(reason)" + } + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportPolicy.swift b/Shared/Flyover/Sources/FlyoverExportPolicy.swift new file mode 100644 index 000000000..1d0e8e8af --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportPolicy.swift @@ -0,0 +1,63 @@ +#if DEBUG + import SnapshotKit + + /// The sizing and readiness behavior for one exported Flyover variant. + @MainActor + public struct FlyoverExportPolicy { + public let captureExtent: FlyoverCaptureExtent + public let measurementReadiness: SnapshotMeasurementReadiness + public let settle: SnapshotSettle + public let onReadyToMeasure: (@MainActor () async -> Void)? + public let onReadyToSnapshot: (@MainActor () async -> Void)? + + public init( + captureExtent: FlyoverCaptureExtent, + measurementReadiness: SnapshotMeasurementReadiness, + settle: SnapshotSettle, + onReadyToMeasure: (@MainActor () async -> Void)?, + onReadyToSnapshot: (@MainActor () async -> Void)?, + ) { + self.captureExtent = captureExtent + self.measurementReadiness = measurementReadiness + self.settle = settle + self.onReadyToMeasure = onReadyToMeasure + self.onReadyToSnapshot = onReadyToSnapshot + } + + public static var hosted: Self { + FlyoverExportPolicy( + captureExtent: .viewport, + measurementReadiness: .sameAsCapture, + settle: .settled, + onReadyToMeasure: nil, + onReadyToSnapshot: nil, + ) + } + + static func resolution(for snapshotCase: SnapshotCase) -> FlyoverExportPolicyResolution { + let extents = Set(snapshotCase.configurations.map { configuration in + switch configuration.device.size { + case .fixed: FlyoverCaptureExtent.viewport + case .intrinsic: FlyoverCaptureExtent.intrinsic + case .fullContent: FlyoverCaptureExtent.fullContent + case .fullContent2D: FlyoverCaptureExtent.fullContent2D + } + }) + guard extents.count <= 1 else { + return .mixed(extents.sorted { $0.rawValue < $1.rawValue }) + } + return .policy(FlyoverExportPolicy( + captureExtent: extents.first ?? .viewport, + measurementReadiness: snapshotCase.measurementReadiness, + settle: snapshotCase.settle, + onReadyToMeasure: snapshotCase.onReadyToMeasure, + onReadyToSnapshot: snapshotCase.onReadyToSnapshot, + )) + } + } + + enum FlyoverExportPolicyResolution { + case policy(FlyoverExportPolicy) + case mixed([FlyoverCaptureExtent]) + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportSummary.swift b/Shared/Flyover/Sources/FlyoverExportSummary.swift new file mode 100644 index 000000000..2eb469614 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportSummary.swift @@ -0,0 +1,29 @@ +#if DEBUG + import Foundation + + /// Counts and output details from a completed static export. + public struct FlyoverExportSummary: Equatable, Sendable { + public let screenCount: Int + public let stateCount: Int + public let profileCount: Int + public let imageCount: Int + public let outputByteCount: Int + public let outputDirectory: URL + + public init( + screenCount: Int, + stateCount: Int, + profileCount: Int, + imageCount: Int, + outputByteCount: Int, + outputDirectory: URL, + ) { + self.screenCount = screenCount + self.stateCount = stateCount + self.profileCount = profileCount + self.imageCount = imageCount + self.outputByteCount = outputByteCount + self.outputDirectory = outputDirectory + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverVariant.swift b/Shared/Flyover/Sources/FlyoverVariant.swift index 1d8744e40..301e326d5 100644 --- a/Shared/Flyover/Sources/FlyoverVariant.swift +++ b/Shared/Flyover/Sources/FlyoverVariant.swift @@ -8,6 +8,10 @@ public struct FlyoverVariant { public let title: String let overviewContent: @MainActor () -> AnyView let focusedContent: @MainActor () -> AnyView + #if DEBUG + public let exportPolicy: FlyoverExportPolicy? + let exportPolicyResolution: FlyoverExportPolicyResolution + #endif public init( id: FlyoverVariantID, @@ -18,6 +22,10 @@ public struct FlyoverVariant { self.title = title overviewContent = { AnyView(content()) } focusedContent = { AnyView(content()) } + #if DEBUG + exportPolicy = .hosted + exportPolicyResolution = .policy(.hosted) + #endif } public init( @@ -30,6 +38,10 @@ public struct FlyoverVariant { self.title = title overviewContent = { AnyView(overview()) } focusedContent = { AnyView(focused()) } + #if DEBUG + exportPolicy = .hosted + exportPolicyResolution = .policy(.hosted) + #endif } /// Adapts existing snapshot content into a Flyover variant. @@ -38,5 +50,41 @@ public struct FlyoverVariant { title = snapshotCase.name overviewContent = { snapshotCase.content } focusedContent = { snapshotCase.content } + #if DEBUG + exportPolicyResolution = FlyoverExportPolicy.resolution(for: snapshotCase) + exportPolicy = switch exportPolicyResolution { + case let .policy(policy): policy + case .mixed: nil + } + #endif } + + #if DEBUG + public init( + id: FlyoverVariantID, + title: String, + exportPolicy: FlyoverExportPolicy, + @ViewBuilder content: @escaping @MainActor () -> some View, + ) { + self.id = id + self.title = title + overviewContent = { AnyView(content()) } + focusedContent = { AnyView(content()) } + self.exportPolicy = exportPolicy + exportPolicyResolution = .policy(exportPolicy) + } + + public init( + id: FlyoverVariantID, + snapshotCase: SnapshotCase, + exportPolicy: FlyoverExportPolicy, + ) { + self.id = id + title = snapshotCase.name + overviewContent = { snapshotCase.content } + focusedContent = { snapshotCase.content } + self.exportPolicy = exportPolicy + exportPolicyResolution = .policy(exportPolicy) + } + #endif } diff --git a/Shared/Flyover/Sources/FlyoverWebExporter.swift b/Shared/Flyover/Sources/FlyoverWebExporter.swift new file mode 100644 index 000000000..3d2155ce7 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverWebExporter.swift @@ -0,0 +1,651 @@ +#if DEBUG + import Foundation + import SnapshotKit + import SwiftUI + + /// Converts a typed Flyover catalog into a static manifest and PNG set. + @MainActor + public struct FlyoverWebExporter { + private let catalog: FlyoverCatalog + private let applicationID: String + private let title: String + private let screenIdentifier: (ScreenID) -> String + + public init( + catalog: FlyoverCatalog, + applicationID: String, + title: String, + screenIdentifier: @escaping (ScreenID) -> String, + ) { + self.catalog = catalog + self.applicationID = applicationID + self.title = title + self.screenIdentifier = screenIdentifier + } + + public func export( + to directory: URL, + profiles requestedProfiles: [FlyoverCaptureProfile], + build: FlyoverExportBuild, + capture: @escaping (FlyoverCaptureRequest) async throws -> FlyoverCapturedImage, + ) async throws -> FlyoverExportSummary { + let profiles = FlyoverCaptureProfile.orderedUnique(requestedProfiles) + let preparedScreens = try prepareScreens() + try validatePolicies(in: preparedScreens) + let layout = FlyoverLayout(catalog: catalog, style: FlyoverStylesheet.default.layout) + .resolve() + let preparedRoutes = try prepareRoutes(screens: preparedScreens, layout: layout) + + let fileManager = FileManager.default + let imagesDirectory = directory.appending(path: "images", directoryHint: .isDirectory) + do { + try fileManager.createDirectory( + at: imagesDirectory, + withIntermediateDirectories: true, + ) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: imagesDirectory.path, + reason: error.localizedDescription, + ) + } + + var images: [FlyoverWebManifest.Image] = [] + var pathsByVariant: [VariantKey: [String: String]] = [:] + let captureCount = preparedScreens.reduce(0) { count, prepared in + count + prepared.screen.variants.count * profiles.count + } + var captureIndex = 0 + + for prepared in preparedScreens { + for (variantIndex, variant) in prepared.screen.variants.enumerated() { + let policy = try resolvedPolicy( + variant, + screen: prepared.stableID, + ) + for profile in profiles { + try Task.checkCancellation() + captureIndex += 1 + prepared.screen.resetAction() + let configuration = profile.configuration( + viewport: prepared.screen.viewport, + captureExtent: policy.captureExtent, + ) + let relativePath = String( + format: "images/screen-%04d/variant-%04d/%@.png", + prepared.screenOrdinal, + variantIndex + 1, + profile.rawValue, + ) + let captureName = "\(prepared.stableID).\(variant.id.rawValue).\(profile.rawValue)" + let request = FlyoverCaptureRequest( + groupTitle: prepared.groupTitle, + screenID: prepared.stableID, + screenTitle: prepared.screen.title, + variantID: variant.id.rawValue, + variantTitle: variant.title, + profile: profile, + configuration: configuration, + policy: policy, + captureName: captureName, + content: AnyView(FlyoverExportContent( + navigationContainer: prepared.screen.navigationContainer, + content: variant.overviewContent(), + )), + ) + print( + "FLYOVER_EXPORT \(captureIndex)/\(captureCount) " + + + "\(prepared.screen.title) / \(variant.title) / \(profile.rawValue)", + ) + + let captured: FlyoverCapturedImage + do { + captured = try await capture(request) + } catch is CancellationError { + throw CancellationError() + } catch { + throw FlyoverExportError.captureFailed( + group: prepared.groupTitle, + screen: prepared.screen.title, + variant: variant.title, + profile: profile.rawValue, + phase: "capture", + reason: error.localizedDescription, + ) + } + try Task.checkCancellation() + guard captured.pngData.isEmpty == false else { + throw FlyoverExportError.emptyPNG( + screen: prepared.screen.title, + variant: variant.title, + profile: profile.rawValue, + ) + } + + let imageURL = directory.appending(path: relativePath) + let thumbnailRelativePath = String( + format: "images/screen-%04d/variant-%04d/%@-thumbnail.png", + prepared.screenOrdinal, + variantIndex + 1, + profile.rawValue, + ) + let thumbnail: FlyoverWebThumbnail + do { + thumbnail = try await FlyoverWebThumbnail.make( + from: captured.pngData, + pointSize: captured.pointSize, + viewportPointSize: configuration.thumbnailViewportPointSize, + ) + } catch is CancellationError { + throw CancellationError() + } catch { + throw FlyoverExportError.captureFailed( + group: prepared.groupTitle, + screen: prepared.screen.title, + variant: variant.title, + profile: profile.rawValue, + phase: "thumbnail generation", + reason: error.localizedDescription, + ) + } + try Task.checkCancellation() + let thumbnailURL = directory.appending(path: thumbnailRelativePath) + do { + try fileManager.createDirectory( + at: imageURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: imageURL.deletingLastPathComponent().path, + reason: error.localizedDescription, + ) + } + do { + try captured.pngData.write(to: imageURL, options: .atomic) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: imageURL.path, + reason: error.localizedDescription, + ) + } + do { + try thumbnail.pngData.write(to: thumbnailURL, options: .atomic) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: thumbnailURL.path, + reason: error.localizedDescription, + ) + } + + let key = VariantKey( + screenID: prepared.stableID, + variantID: variant.id.rawValue, + ) + pathsByVariant[key, default: [:]][profile.rawValue] = relativePath + images.append(FlyoverWebManifest.Image( + screenID: prepared.stableID, + variantID: variant.id.rawValue, + profileID: profile.rawValue, + relativePath: relativePath, + thumbnailRelativePath: thumbnailRelativePath, + pointWidth: Double(captured.pointSize.width), + pointHeight: Double(captured.pointSize.height), + pixelWidth: Int(captured.pixelSize.width.rounded()), + pixelHeight: Int(captured.pixelSize.height.rounded()), + thumbnailPixelWidth: Int(thumbnail.pixelSize.width.rounded()), + thumbnailPixelHeight: Int(thumbnail.pixelSize.height.rounded()), + scale: Double(captured.scale), + captureExtent: policy.captureExtent.rawValue, + )) + } + } + } + + try Task.checkCancellation() + let manifest = try makeManifest( + build: build, + profiles: profiles, + screens: preparedScreens, + routes: preparedRoutes, + layout: layout, + pathsByVariant: pathsByVariant, + images: images, + ) + try write(manifest: manifest, to: directory) + let actualImageCount = try pngCount(in: imagesDirectory) + let expectedImageCount = images.count * 2 + guard actualImageCount == expectedImageCount else { + throw FlyoverExportError.assetCountMismatch( + expected: expectedImageCount, + actual: actualImageCount, + ) + } + + return try FlyoverExportSummary( + screenCount: preparedScreens.count, + stateCount: preparedScreens.reduce(0) { $0 + $1.screen.variants.count }, + profileCount: profiles.count, + imageCount: images.count, + outputByteCount: directoryByteCount(directory), + outputDirectory: directory, + ) + } + + private func prepareScreens() throws -> [PreparedScreen] { + guard catalog.isValid else { + throw FlyoverExportError.invalidCatalog(issueCount: catalog.validationIssues.count) + } + guard applicationID.isEmpty == false else { + throw FlyoverExportError.emptyApplicationIdentifier + } + + var seenScreenIDs: Set = [] + var prepared: [PreparedScreen] = [] + var screenOrdinal = 0 + for (groupIndex, group) in catalog.groups.enumerated() { + for (screenIndex, screen) in group.screens.enumerated() { + screenOrdinal += 1 + let stableID = screenIdentifier(screen.id) + guard stableID.isEmpty == false else { + throw FlyoverExportError.emptyScreenIdentifier( + screenTitle: screen.title, + ) + } + guard seenScreenIDs.insert(stableID).inserted else { + throw FlyoverExportError.duplicateScreenIdentifier(stableID) + } + var seenVariants: Set = [] + for variant in screen.variants { + guard variant.id.rawValue.isEmpty == false else { + throw FlyoverExportError.emptyVariantIdentifier(screen: stableID) + } + guard seenVariants.insert(variant.id.rawValue).inserted else { + throw FlyoverExportError.duplicateVariantIdentifier( + screen: stableID, + variant: variant.id.rawValue, + ) + } + } + prepared.append(PreparedScreen( + groupID: group.id.rawValue, + groupTitle: group.title, + groupOrder: groupIndex, + screenOrder: screenIndex, + screenOrdinal: screenOrdinal, + stableID: stableID, + screen: screen, + )) + } + } + return prepared + } + + private func validatePolicies(in screens: [PreparedScreen]) throws { + for prepared in screens { + for variant in prepared.screen.variants { + _ = try resolvedPolicy(variant, screen: prepared.stableID) + } + } + } + + private func resolvedPolicy( + _ variant: FlyoverVariant, + screen: String, + ) throws -> FlyoverExportPolicy { + let policy = switch variant.exportPolicyResolution { + case let .policy(policy): + policy + case let .mixed(extents): + throw FlyoverExportError.mixedSizingPolicy( + screen: screen, + variant: variant.id.rawValue, + extents: extents.map(\.rawValue), + ) + } + guard policy.captureExtent != .viewport || policy.onReadyToMeasure == nil else { + throw FlyoverExportError.measurementHookRequiresMeasuredSizing( + screen: screen, + variant: variant.id.rawValue, + ) + } + return policy + } + + private func prepareRoutes( + screens: [PreparedScreen], + layout: FlyoverLayoutResult, + ) throws -> [PreparedRoute] { + let stableIDs = stableIDMap(for: screens) + return try catalog.transitions.enumerated().map { index, transition in + guard let sourceID = stableIDs[transition.source], + let destinationID = stableIDs[transition.destination], + let sourceFrame = layout.screenFrames[transition.source], + let destinationFrame = layout.screenFrames[transition.destination] + else { + throw FlyoverExportError.missingManifestGeometry( + kind: "route", + identifier: String(format: "route-%04d", index + 1), + ) + } + let geometry = FlyoverConnectorGeometry( + source: sourceFrame, + destination: destinationFrame, + style: FlyoverStylesheet.default.connector, + ) + return PreparedRoute( + id: String(format: "route-%04d", index + 1), + sourceID: sourceID, + destinationID: destinationID, + kind: transition.kind.rawValue, + label: transition.label, + geometry: geometry, + ) + } + } + + private func makeManifest( + build: FlyoverExportBuild, + profiles: [FlyoverCaptureProfile], + screens: [PreparedScreen], + routes: [PreparedRoute], + layout: FlyoverLayoutResult, + pathsByVariant: [VariantKey: [String: String]], + images: [FlyoverWebManifest.Image], + ) throws -> FlyoverWebManifest { + let stableIDs = stableIDMap(for: screens) + let groups = try catalog.groups.enumerated().map { index, group in + guard let rootScreenID = stableIDs[group.root] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "group root", + identifier: group.id.rawValue, + ) + } + let screenIDs = try group.screens.map { screen in + guard let stableID = stableIDs[screen.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "screen identity", + identifier: screen.title, + ) + } + return stableID + } + return FlyoverWebManifest.Group( + id: group.id.rawValue, + title: group.title, + order: index, + rootScreenID: rootScreenID, + screenIDs: screenIDs, + ) + } + let manifestRoutes = routes.map { route in + FlyoverWebManifest.Route( + id: route.id, + sourceScreenID: route.sourceID, + destinationScreenID: route.destinationID, + kind: route.kind, + label: route.label, + geometry: route.geometry.manifestValue, + ) + } + let manifestScreens = try screens.map { prepared in + guard let screenFrame = layout.screenFrames[prepared.screen.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "screen frame", + identifier: prepared.stableID, + ) + } + let variants = try prepared.screen.variants.map { variant in + let policy = try resolvedPolicy(variant, screen: prepared.stableID) + let key = VariantKey( + screenID: prepared.stableID, + variantID: variant.id.rawValue, + ) + return FlyoverWebManifest.Variant( + id: variant.id.rawValue, + title: variant.title, + captureExtent: policy.captureExtent.rawValue, + imagesByProfile: pathsByVariant[key] ?? [:], + ) + } + return FlyoverWebManifest.Screen( + id: prepared.stableID, + title: prepared.screen.title, + groupID: prepared.groupID, + groupOrder: prepared.groupOrder, + screenOrder: prepared.screenOrder, + viewport: prepared.screen.viewport.manifestValue, + navigationContainer: prepared.screen.navigationContainer.manifestValue, + frame: FlyoverWebManifest.Rect(screenFrame), + variants: variants, + incomingRouteIDs: routes + .filter { $0.destinationID == prepared.stableID } + .map(\.id), + outgoingRouteIDs: routes + .filter { $0.sourceID == prepared.stableID } + .map(\.id), + ) + } + let groupFrames = try catalog.groups + .map { group -> FlyoverWebManifest.IdentifiedFrame in + guard let frame = layout.groupFrames[group.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "group frame", + identifier: group.id.rawValue, + ) + } + return FlyoverWebManifest.IdentifiedFrame( + id: group.id.rawValue, + frame: FlyoverWebManifest.Rect(frame), + ) + } + let screenFrames = try screens + .map { prepared -> FlyoverWebManifest.IdentifiedFrame in + guard let frame = layout.screenFrames[prepared.screen.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "screen frame", + identifier: prepared.stableID, + ) + } + return FlyoverWebManifest.IdentifiedFrame( + id: prepared.stableID, + frame: FlyoverWebManifest.Rect(frame), + ) + } + let depthBands = layout.depthBands.map { band in + let kind: String + let depth: Int? + switch band.kind { + case let .route(value): + kind = "route" + depth = value + case .unlinked: + kind = "unlinked" + depth = nil + } + return FlyoverWebManifest.DepthBandFrame( + groupID: band.id.group.rawValue, + kind: kind, + depth: depth, + frame: FlyoverWebManifest.Rect(band.frame), + ) + } + return FlyoverWebManifest( + schemaVersion: 1, + application: FlyoverWebManifest.Application(id: applicationID, title: title), + build: build, + profiles: profiles.map(\.manifestValue), + canvas: FlyoverWebManifest.Canvas( + size: FlyoverWebManifest.Size(layout.canvasSize), + initialFitSize: FlyoverWebManifest.Size(layout.initialCanvasSize), + groupFrames: groupFrames, + depthBandFrames: depthBands, + screenFrames: screenFrames, + connectors: routes.map { + FlyoverWebManifest.Connector( + routeID: $0.id, + geometry: $0.geometry.manifestValue, + ) + }, + ), + groups: groups, + screens: manifestScreens, + routes: manifestRoutes, + images: images, + ) + } + + private func stableIDMap(for screens: [PreparedScreen]) -> [ScreenID: String] { + screens.reduce(into: [:]) { result, prepared in + result[prepared.screen.id] = prepared.stableID + } + } + + private func write(manifest: FlyoverWebManifest, to directory: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data: Data + do { + data = try encoder.encode(manifest) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: directory.appending(path: "manifest.json").path, + reason: error.localizedDescription, + ) + } + do { + try data.write( + to: directory.appending(path: "manifest.json"), + options: .atomic, + ) + var script = Data("window.FLYOVER_MANIFEST = ".utf8) + script.append(data) + script.append(Data(";\n".utf8)) + try script.write( + to: directory.appending(path: "manifest.js"), + options: .atomic, + ) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: directory.path, + reason: error.localizedDescription, + ) + } + } + + private func pngCount(in directory: URL) throws -> Int { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + ) else { + return 0 + } + return enumerator.compactMap { $0 as? URL } + .count(where: { $0.pathExtension == "png" }) + } + + private func directoryByteCount(_ directory: URL) throws -> Int { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.fileSizeKey], + ) else { + return 0 + } + return try enumerator.compactMap { value -> Int? in + guard let url = value as? URL else { return nil } + return try url.resourceValues(forKeys: [.fileSizeKey]).fileSize + }.reduce(0, +) + } + + private struct PreparedScreen { + let groupID: String + let groupTitle: String + let groupOrder: Int + let screenOrder: Int + let screenOrdinal: Int + let stableID: String + let screen: FlyoverScreen + } + + private struct PreparedRoute { + let id: String + let sourceID: String + let destinationID: String + let kind: String + let label: String? + let geometry: FlyoverConnectorGeometry + } + + private struct VariantKey: Hashable { + let screenID: String + let variantID: String + } + } + + extension SnapshotConfiguration { + fileprivate var thumbnailViewportPointSize: CGSize? { + switch device.size { + case .fixed, .intrinsic: + nil + case let .fullContent(width, minimumHeight): + minimumHeight.map { CGSize(width: width, height: $0) } + case let .fullContent2D(minimumSize): + minimumSize + } + } + } + + extension FlyoverCaptureProfile { + fileprivate var manifestValue: FlyoverWebManifest.Profile { + FlyoverWebManifest.Profile( + id: rawValue, + title: title, + device: deviceName, + orientation: orientationName, + colorScheme: colorSchemeName, + dynamicType: dynamicTypeName, + contrast: contrastName, + layoutDirection: layoutDirectionName, + legibilityWeight: legibilityWeightName, + snapshotType: snapshotTypeName, + ) + } + } + + extension FlyoverViewport { + fileprivate var manifestValue: FlyoverWebManifest.Viewport { + switch self { + case .device: + FlyoverWebManifest.Viewport(kind: "device", fixedSize: nil) + case let .fixed(size): + FlyoverWebManifest.Viewport( + kind: "fixed", + fixedSize: FlyoverWebManifest.Size(size), + ) + } + } + } + + extension FlyoverNavigationContainer { + fileprivate var manifestValue: String { + switch self { + case .stack: "stack" + case .none: "none" + } + } + } + + extension FlyoverConnectorGeometry { + fileprivate var manifestValue: FlyoverWebManifest.ConnectorGeometry { + FlyoverWebManifest.ConnectorGeometry( + start: FlyoverWebManifest.Point(start), + end: FlyoverWebManifest.Point(end), + firstControl: FlyoverWebManifest.Point(firstControl), + secondControl: FlyoverWebManifest.Point(secondControl), + firstArrowPoint: FlyoverWebManifest.Point(firstArrowPoint), + secondArrowPoint: FlyoverWebManifest.Point(secondArrowPoint), + ) + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverWebManifest.swift b/Shared/Flyover/Sources/FlyoverWebManifest.swift new file mode 100644 index 000000000..912da65f5 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverWebManifest.swift @@ -0,0 +1,163 @@ +#if DEBUG + import CoreGraphics + import Foundation + + /// The versioned data model consumed by a generated Flyover website. + public struct FlyoverWebManifest: Codable, Sendable { + public let schemaVersion: Int + public let application: Application + public let build: FlyoverExportBuild + public let profiles: [Profile] + public let canvas: Canvas + public let groups: [Group] + public let screens: [Screen] + public let routes: [Route] + public let images: [Image] + + public struct Application: Codable, Sendable { + public let id: String + public let title: String + } + + public struct Profile: Codable, Sendable { + public let id: String + public let title: String + public let device: String + public let orientation: String + public let colorScheme: String + public let dynamicType: String + public let contrast: String + public let layoutDirection: String + public let legibilityWeight: String + public let snapshotType: String + } + + public struct Canvas: Codable, Sendable { + public let size: Size + public let initialFitSize: Size + public let groupFrames: [IdentifiedFrame] + public let depthBandFrames: [DepthBandFrame] + public let screenFrames: [IdentifiedFrame] + public let connectors: [Connector] + } + + public struct Group: Codable, Sendable { + public let id: String + public let title: String + public let order: Int + public let rootScreenID: String + public let screenIDs: [String] + } + + public struct Screen: Codable, Sendable { + public let id: String + public let title: String + public let groupID: String + public let groupOrder: Int + public let screenOrder: Int + public let viewport: Viewport + public let navigationContainer: String + public let frame: Rect + public let variants: [Variant] + public let incomingRouteIDs: [String] + public let outgoingRouteIDs: [String] + } + + public struct Viewport: Codable, Sendable { + public let kind: String + public let fixedSize: Size? + } + + public struct Variant: Codable, Sendable { + public let id: String + public let title: String + public let captureExtent: String + public let imagesByProfile: [String: String] + } + + public struct Route: Codable, Sendable { + public let id: String + public let sourceScreenID: String + public let destinationScreenID: String + public let kind: String + public let label: String? + public let geometry: ConnectorGeometry + } + + public struct Image: Codable, Sendable { + public let screenID: String + public let variantID: String + public let profileID: String + public let relativePath: String + public let thumbnailRelativePath: String? + public let pointWidth: Double + public let pointHeight: Double + public let pixelWidth: Int + public let pixelHeight: Int + public let thumbnailPixelWidth: Int? + public let thumbnailPixelHeight: Int? + public let scale: Double + public let captureExtent: String + } + + public struct IdentifiedFrame: Codable, Sendable { + public let id: String + public let frame: Rect + } + + public struct DepthBandFrame: Codable, Sendable { + public let groupID: String + public let kind: String + public let depth: Int? + public let frame: Rect + } + + public struct Connector: Codable, Sendable { + public let routeID: String + public let geometry: ConnectorGeometry + } + + public struct ConnectorGeometry: Codable, Sendable { + public let start: Point + public let end: Point + public let firstControl: Point + public let secondControl: Point + public let firstArrowPoint: Point + public let secondArrowPoint: Point + } + + public struct Rect: Codable, Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + init(_ value: CGRect) { + x = Double(value.origin.x) + y = Double(value.origin.y) + width = Double(value.size.width) + height = Double(value.size.height) + } + } + + public struct Point: Codable, Equatable, Sendable { + public let x: Double + public let y: Double + + init(_ value: CGPoint) { + x = Double(value.x) + y = Double(value.y) + } + } + + public struct Size: Codable, Equatable, Sendable { + public let width: Double + public let height: Double + + init(_ value: CGSize) { + width = Double(value.width) + height = Double(value.height) + } + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverWebThumbnail.swift b/Shared/Flyover/Sources/FlyoverWebThumbnail.swift new file mode 100644 index 000000000..92b742d92 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverWebThumbnail.swift @@ -0,0 +1,128 @@ +#if DEBUG + import CoreGraphics + import Foundation + import ImageIO + + /// Builds a card-sized PNG from one hosted full-resolution capture. + struct FlyoverWebThumbnail { + static let maximumPixelDimension = 1024 + + let pngData: Data + let pixelSize: CGSize + + @concurrent + static func make( + from pngData: Data, + pointSize: CGSize, + viewportPointSize: CGSize?, + ) async throws -> Self { + try Task.checkCancellation() + guard let source = CGImageSourceCreateWithData(pngData as CFData, nil), + let sourceImage = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + throw FlyoverWebThumbnailError.invalidPNG + } + + let croppedImage = try crop( + sourceImage, + pointSize: pointSize, + viewportPointSize: viewportPointSize, + ) + let longestDimension = max(croppedImage.width, croppedImage.height) + let scale = min( + 1, + CGFloat(maximumPixelDimension) / CGFloat(max(longestDimension, 1)), + ) + let width = max(1, Int((CGFloat(croppedImage.width) * scale).rounded())) + let height = max(1, Int((CGFloat(croppedImage.height) * scale).rounded())) + + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, + ) + else { + throw FlyoverWebThumbnailError.couldNotCreateBitmap + } + context.interpolationQuality = .high + context.draw(croppedImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + try Task.checkCancellation() + + guard let thumbnailImage = context.makeImage() else { + throw FlyoverWebThumbnailError.couldNotCreateBitmap + } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, + "public.png" as CFString, + 1, + nil, + ) else { + throw FlyoverWebThumbnailError.couldNotCreatePNG + } + CGImageDestinationAddImage(destination, thumbnailImage, nil) + guard CGImageDestinationFinalize(destination) else { + throw FlyoverWebThumbnailError.couldNotCreatePNG + } + return FlyoverWebThumbnail( + pngData: output as Data, + pixelSize: CGSize(width: width, height: height), + ) + } + + private static func crop( + _ image: CGImage, + pointSize: CGSize, + viewportPointSize: CGSize?, + ) throws -> CGImage { + guard let viewportPointSize else { + return image + } + guard pointSize.width > 0, pointSize.height > 0 else { + throw FlyoverWebThumbnailError.invalidPointSize + } + let width = min( + CGFloat(image.width), + viewportPointSize.width * CGFloat(image.width) / pointSize.width, + ) + let height = min( + CGFloat(image.height), + viewportPointSize.height * CGFloat(image.height) / pointSize.height, + ) + guard width > 0, height > 0, + let cropped = image.cropping(to: CGRect(x: 0, y: 0, width: width, height: height)) + else { + throw FlyoverWebThumbnailError.couldNotCrop + } + return cropped + } + } + + enum FlyoverWebThumbnailError: Error, Equatable, LocalizedError { + case invalidPNG + case invalidPointSize + case couldNotCrop + case couldNotCreateBitmap + case couldNotCreatePNG + + var errorDescription: String? { + switch self { + case .invalidPNG: + "The hosted capture is not a valid PNG." + case .invalidPointSize: + "The hosted capture has an invalid point size." + case .couldNotCrop: + "The capture viewport could not be cropped." + case .couldNotCreateBitmap: + "The thumbnail bitmap could not be created." + case .couldNotCreatePNG: + "The thumbnail PNG could not be encoded." + } + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift new file mode 100644 index 000000000..ad749c00b --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift @@ -0,0 +1,62 @@ +#if DEBUG + import CoreGraphics + @testable import Flyover + import SnapshotKit + import Testing + + struct FlyoverCaptureProfileTests { + @Test func emptyRequestUsesLightAndDarkPhones() throws { + #expect(try FlyoverCaptureProfile.parse([]) == [.phoneLight, .phoneDark]) + } + + @Test func emptyTypedRequestUsesLightAndDarkPhones() { + #expect(FlyoverCaptureProfile.orderedUnique([]) == [.phoneLight, .phoneDark]) + } + + @Test func preservesFirstOccurrenceOrderAndRemovesDuplicates() throws { + let profiles = try FlyoverCaptureProfile.parse([ + "phone-dark", + "phone-light", + "phone-dark", + ]) + + #expect(profiles == [.phoneDark, .phoneLight]) + } + + @Test func rejectsUnknownProfile() { + #expect(throws: FlyoverExportError.unknownProfile("system")) { + try FlyoverCaptureProfile.parse(["system"]) + } + } + + @Test func deviceProfilesDeclareTruthfulAdaptiveLayoutTraits() { + let phone = FlyoverCaptureProfile.phoneLight.configuration( + viewport: .device, + captureExtent: .viewport, + ) + let tablet = FlyoverCaptureProfile.tabletLight.configuration( + viewport: .device, + captureExtent: .viewport, + ) + let landscape = FlyoverCaptureProfile.phoneLandscape.configuration( + viewport: .device, + captureExtent: .viewport, + ) + + #expect(phone.layoutTraits == .phonePortrait) + #expect(tablet.layoutTraits == .tabletPortrait) + #expect(landscape.layoutTraits == .phoneLandscape) + } + + @Test func fixedViewportRetainsItsSizeWhileProfileLayoutTraitsApply() { + let fixedSize = CGSize(width: 320, height: 180) + let configuration = FlyoverCaptureProfile.tabletLight.configuration( + viewport: .fixed(fixedSize), + captureExtent: .viewport, + ) + + #expect(configuration.device.size == .fixed(fixedSize)) + #expect(configuration.layoutTraits == .tabletPortrait) + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverExportPolicyTests.swift b/Shared/Flyover/Tests/FlyoverExportPolicyTests.swift new file mode 100644 index 000000000..8db72f35a --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverExportPolicyTests.swift @@ -0,0 +1,77 @@ +#if DEBUG + @testable import Flyover + import SnapshotKit + import SwiftUI + import Testing + + @MainActor + struct FlyoverExportPolicyTests { + @Test func emptySnapshotMatrixUsesViewportAndPreservesReadiness() { + let snapshotCase = SnapshotCase( + name: "Empty", + configurations: [], + measurementReadiness: .immediate, + settle: .immediate, + ) { + EmptyView() + } + + let resolution = FlyoverExportPolicy.resolution(for: snapshotCase) + guard case let .policy(policy) = resolution else { + Issue.record("Expected a resolved export policy.") + return + } + #expect(policy.captureExtent == .viewport) + #expect(policy.measurementReadiness == .immediate) + #expect(policy.settle == .immediate) + } + + @Test( + arguments: [ + SizingExpectation(frame: .iPhone, extent: .viewport), + SizingExpectation(frame: .component, extent: .intrinsic), + SizingExpectation(frame: .iPhoneFullContent, extent: .fullContent), + SizingExpectation(frame: .iPhoneFullContent2D, extent: .fullContent2D), + ], + ) + func reducesOneSizingClass(expectation: SizingExpectation) { + let snapshotCase = SnapshotCase( + name: "Sizing", + configurations: [SnapshotConfiguration(device: expectation.frame)], + ) { + EmptyView() + } + + let resolution = FlyoverExportPolicy.resolution(for: snapshotCase) + guard case let .policy(policy) = resolution else { + Issue.record("Expected a resolved export policy.") + return + } + #expect(policy.captureExtent == expectation.extent) + } + + @Test func reportsMixedSizingClasses() { + let snapshotCase = SnapshotCase( + name: "Mixed", + configurations: [ + SnapshotConfiguration(device: .iPhone), + SnapshotConfiguration(device: .iPhoneFullContent), + ], + ) { + EmptyView() + } + + let resolution = FlyoverExportPolicy.resolution(for: snapshotCase) + guard case let .mixed(extents) = resolution else { + Issue.record("Expected a mixed sizing policy.") + return + } + #expect(Set(extents) == [.viewport, .fullContent]) + } + + struct SizingExpectation { + let frame: SnapshotConfiguration.Frame + let extent: FlyoverCaptureExtent + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverTestScreen.swift b/Shared/Flyover/Tests/FlyoverTestScreen.swift index 3e2399d16..e7c5a6580 100644 --- a/Shared/Flyover/Tests/FlyoverTestScreen.swift +++ b/Shared/Flyover/Tests/FlyoverTestScreen.swift @@ -1,4 +1,4 @@ -enum FlyoverTestScreen: Hashable { +enum FlyoverTestScreen: String, Hashable { case root case pushed case modal diff --git a/Shared/Flyover/Tests/FlyoverVariantTests.swift b/Shared/Flyover/Tests/FlyoverVariantTests.swift index 0fd60ad29..805b23722 100644 --- a/Shared/Flyover/Tests/FlyoverVariantTests.swift +++ b/Shared/Flyover/Tests/FlyoverVariantTests.swift @@ -55,4 +55,30 @@ struct FlyoverVariantTests { _ = variant.overviewContent() #expect(buildCount == 1) } + + @Test func mixedSnapshotSizingDoesNotExposeAFallbackPolicy() { + let snapshotCase = SnapshotCase( + name: "Mixed", + configurations: [ + SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "viewport", + size: .fixed(CGSize(width: 100, height: 100)), + ), + ), + SnapshotConfiguration( + device: .fullContent(name: "full-content", width: 100), + ), + ], + ) { + Color.red + } + + let variant = FlyoverVariant( + id: FlyoverVariantID("mixed"), + snapshotCase: snapshotCase, + ) + + #expect(variant.exportPolicy == nil) + } } diff --git a/Shared/Flyover/Tests/FlyoverWebExporterTests.swift b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift new file mode 100644 index 000000000..4e9df4137 --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift @@ -0,0 +1,410 @@ +#if DEBUG + @testable import Flyover + import Foundation + import SnapshotKit + import SwiftUI + import Testing + + @MainActor + struct FlyoverWebExporterTests { + @Test func writesAStableManifestAndEveryRequestedImage() async throws { + var resetCount = 0 + let catalog = makeCatalog(reset: { resetCount += 1 }) + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test-app", + title: "Test App", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + let pngData = try #require(Data(base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")) + + let summary = try await exporter.export( + to: directory, + profiles: [.phoneLight, .phoneDark, .phoneLight], + build: build, + ) { request in + FlyoverCapturedImage( + pngData: pngData, + pointSize: request.configuration.device.testPointSize, + pixelSize: CGSize(width: 1206, height: 2622), + scale: 3, + ) + } + + #expect(summary.screenCount == 3) + #expect(summary.stateCount == 4) + #expect(summary.profileCount == 2) + #expect(summary.imageCount == 8) + #expect(resetCount == 4) + + let data = try Data(contentsOf: directory.appending(path: "manifest.json")) + let manifest = try JSONDecoder().decode(FlyoverWebManifest.self, from: data) + #expect(manifest.schemaVersion == 1) + #expect(manifest.application.id == "test-app") + #expect(manifest.application.title == "Test App") + #expect(manifest.groups.map(\.id) == ["main"]) + #expect(manifest.screens.map(\.id) == ["root", "pushed", "modal"]) + #expect(manifest.routes.map(\.id) == ["route-0001", "route-0002"]) + #expect(manifest.images.count == 8) + #expect(manifest.images.allSatisfy { image in + image.relativePath.hasPrefix("images/screen-") + && image.relativePath.hasPrefix("/") == false + && FileManager.default.fileExists( + atPath: directory.appending(path: image.relativePath).path, + ) + && image.thumbnailRelativePath?.hasSuffix("-thumbnail.png") == true + && image.thumbnailRelativePath.map { path in + FileManager.default.fileExists( + atPath: directory.appending(path: path).path, + ) + } == true + && image.thumbnailPixelWidth == 1 + && image.thumbnailPixelHeight == 1 + }) + #expect(try pngCount(in: directory) == 16) + let script = try String( + contentsOf: directory.appending(path: "manifest.js"), + encoding: .utf8, + ) + #expect(script.hasPrefix("window.FLYOVER_MANIFEST = {")) + } + + @Test func validatesEverySizingPolicyBeforeTheFirstCapture() async throws { + let mixed = SnapshotCase( + name: "Mixed", + configurations: [ + SnapshotConfiguration(device: .iPhone), + SnapshotConfiguration(device: .iPhoneFullContent), + ], + ) { + EmptyView() + } + let screen = FlyoverScreen( + id: FlyoverTestScreen.root, + title: "Root", + variants: [FlyoverVariant(id: FlyoverVariantID("mixed"), snapshotCase: mixed)], + ) + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [screen], + ), + ]) + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + var captureCount = 0 + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + captureCount += 1 + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + Issue.record("Expected mixed sizing to fail export planning.") + } catch let error as FlyoverExportError { + #expect(error == .mixedSizingPolicy( + screen: "root", + variant: "mixed", + extents: ["fullContent", "viewport"], + )) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(captureCount == 0) + } + + @Test func rejectsAViewportMeasurementHookBeforeAnyEarlierVariantCaptures() async throws { + let invalidPolicy = FlyoverExportPolicy( + captureExtent: .viewport, + measurementReadiness: .sameAsCapture, + settle: .settled, + onReadyToMeasure: {}, + onReadyToSnapshot: nil, + ) + let screen = FlyoverScreen( + id: FlyoverTestScreen.root, + title: "Root", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("valid"), + title: "Valid", + ) { EmptyView() }, + FlyoverVariant( + id: FlyoverVariantID("invalid"), + title: "Invalid", + exportPolicy: invalidPolicy, + ) { EmptyView() }, + ], + ) + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [screen], + ), + ]) + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + var captureCount = 0 + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + captureCount += 1 + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + Issue.record("Expected the invalid measurement hook to fail preflight.") + } catch let error as FlyoverExportError { + #expect(error == .measurementHookRequiresMeasuredSizing( + screen: "root", + variant: "invalid", + )) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(captureCount == 0) + } + + @Test func rejectsDuplicateStableScreenIdentifiers() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let exporter = FlyoverWebExporter( + catalog: makeCatalog(), + applicationID: "test", + title: "Test", + screenIdentifier: { _ in "duplicate" }, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + Issue.record("Capture must not run after validation fails.") + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: .zero, + pixelSize: .zero, + scale: 1, + ) + } + Issue.record("Expected duplicate identifiers to fail.") + } catch let error as FlyoverExportError { + #expect(error == .duplicateScreenIdentifier("duplicate")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test func cancellationAfterCaptureDoesNotPublishTheImageOrManifest() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let screen = makeFlyoverTestScreen(.root, title: "Root") + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [screen], + ), + ]) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + let export = Task { @MainActor in + try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + withUnsafeCurrentTask { task in task?.cancel() } + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + } + + await #expect(throws: CancellationError.self) { + try await export.value + } + #expect(FileManager.default.fileExists( + atPath: directory.appending(path: "manifest.json").path, + ) == false) + #expect(try pngCount(in: directory) == 0) + } + + @Test func reportsInvalidCapturedPNGAsAThumbnailGenerationFailure() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [makeFlyoverTestScreen(.root, title: "Root")], + ), + ]) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + Issue.record("Expected thumbnail generation to reject the invalid PNG.") + } catch let error as FlyoverExportError { + #expect(error == .captureFailed( + group: "Main", + screen: "Root", + variant: "Default", + profile: "phone-light", + phase: "thumbnail generation", + reason: FlyoverWebThumbnailError.invalidPNG.localizedDescription, + )) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(try pngCount(in: directory) == 0) + #expect(FileManager.default.fileExists( + atPath: directory.appending(path: "manifest.json").path, + ) == false) + } + + private var build: FlyoverExportBuild { + FlyoverExportBuild( + commit: "abc123", + dirty: false, + branch: "tests", + generatedAt: "2026-08-13T12:00:00Z", + xcodeVersion: "Xcode 27.0", + simulatorDevice: "iPhone 17", + simulatorOS: "27.0", + ) + } + + private func makeCatalog( + reset: @escaping @MainActor () -> Void = {}, + ) -> FlyoverCatalog { + FlyoverCatalog( + groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [ + FlyoverScreen( + id: FlyoverTestScreen.root, + title: "Root", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("default"), + title: "Default", + ) { Text("Root") }, + FlyoverVariant( + id: FlyoverVariantID("empty"), + title: "Empty", + ) { EmptyView() }, + ], + reset: reset, + ), + makeFlyoverTestScreen(.pushed, title: "Pushed"), + makeFlyoverTestScreen(.modal, title: "Modal"), + ], + ), + ], + transitions: [ + FlyoverTransition(from: .root, to: .pushed, kind: .push), + FlyoverTransition(from: .root, to: .modal, kind: .modal), + ], + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "FlyoverWebExporterTests-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + ) + return directory + } + + private func pngCount(in directory: URL) throws -> Int { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + ) else { + return 0 + } + return enumerator.compactMap { $0 as? URL } + .count(where: { $0.pathExtension == "png" }) + } + } + + extension SnapshotConfiguration.Frame { + fileprivate var testPointSize: CGSize { + switch size { + case let .fixed(size): size + case let .intrinsic(width): CGSize(width: width ?? 402, height: 1) + case let .fullContent(width, minimumHeight): + CGSize(width: width, height: minimumHeight ?? 1) + case let .fullContent2D(minimumSize): minimumSize + } + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift b/Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift new file mode 100644 index 000000000..c0bc17bf6 --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift @@ -0,0 +1,126 @@ +#if DEBUG + import CoreGraphics + @testable import Flyover + import Foundation + import ImageIO + import Testing + + struct FlyoverWebThumbnailTests { + @Test func cropsFullContentToItsTopViewport() async throws { + let source = try makePNG( + width: 2, + height: 4, + rowColors: [ + RGBA(red: 255, green: 0, blue: 0, alpha: 255), + RGBA(red: 0, green: 255, blue: 0, alpha: 255), + RGBA(red: 0, green: 0, blue: 255, alpha: 255), + RGBA(red: 255, green: 255, blue: 0, alpha: 255), + ], + ) + + let thumbnail = try await FlyoverWebThumbnail.make( + from: source, + pointSize: CGSize(width: 2, height: 4), + viewportPointSize: CGSize(width: 2, height: 2), + ) + + #expect(thumbnail.pixelSize == CGSize(width: 2, height: 2)) + let image = try decode(thumbnail.pngData) + #expect(try pixel(in: image, x: 0, y: 0) == [255, 0, 0, 255]) + #expect(try pixel(in: image, x: 0, y: 1) == [0, 255, 0, 255]) + } + + @Test func capsTheLongestPixelDimension() async throws { + let source = try makePNG( + width: FlyoverWebThumbnail.maximumPixelDimension + 1, + height: 1, + rowColors: [RGBA(red: 42, green: 84, blue: 126, alpha: 255)], + ) + + let thumbnail = try await FlyoverWebThumbnail.make( + from: source, + pointSize: CGSize( + width: FlyoverWebThumbnail.maximumPixelDimension + 1, + height: 1, + ), + viewportPointSize: nil, + ) + + #expect(thumbnail.pixelSize.width == CGFloat(FlyoverWebThumbnail.maximumPixelDimension)) + #expect(thumbnail.pixelSize.height == 1) + } + + @Test func rejectsInvalidPNGData() async { + await #expect(throws: FlyoverWebThumbnailError.invalidPNG) { + try await FlyoverWebThumbnail.make( + from: Data("not a png".utf8), + pointSize: CGSize(width: 1, height: 1), + viewportPointSize: nil, + ) + } + } + + private func makePNG( + width: Int, + height: Int, + rowColors: [RGBA], + ) throws -> Data { + let colors = rowColors.count == 1 + ? Array(repeating: rowColors[0], count: height) + : rowColors + #expect(colors.count == height) + var bytes: [UInt8] = [] + bytes.reserveCapacity(width * height * 4) + for color in colors { + for _ in 0 ..< width { + bytes.append(contentsOf: [color.red, color.green, color.blue, color.alpha]) + } + } + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let colorSpace = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let image = try #require(CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent, + )) + let output = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData( + output, + "public.png" as CFString, + 1, + nil, + )) + CGImageDestinationAddImage(destination, image, nil) + try #require(CGImageDestinationFinalize(destination)) + return output as Data + } + + private func decode(_ data: Data) throws -> CGImage { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + return try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + } + + private func pixel(in image: CGImage, x: Int, y: Int) throws -> [UInt8] { + let provider = try #require(image.dataProvider) + let data = try #require(provider.data) + let bytes = CFDataGetBytePtr(data) + let offset = y * image.bytesPerRow + x * 4 + return (0 ..< 4).map { bytes?[offset + $0] ?? 0 } + } + + private struct RGBA { + let red: UInt8 + let green: UInt8 + let blue: UInt8 + let alpha: UInt8 + } + } +#endif diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh new file mode 100755 index 000000000..3528b03ce --- /dev/null +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -0,0 +1,957 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd -P)" +TEMP="$(mktemp -d "${TMPDIR:-/tmp}/flyover-command-tests.XXXXXX")" +trap 'rm -rf "$TEMP"' EXIT INT TERM + +fail() { + echo "flyover command test failed: $*" >&2 + exit 1 +} + +expect_failure() { + if "$@" >"$TEMP/stdout" 2>"$TEMP/stderr"; then + fail "command unexpectedly succeeded: $*" + fi +} + +SUCCESS_RUNNER="$TEMP/success-runner" +cat >"$SUCCESS_RUNNER" <<'RUNNER' +#!/bin/bash +set -euo pipefail +expected='WhereUISnapshotTests/WhereFlyoverWebExportTests/exportsRequestedAtlas()' +if [ "$#" -ne 2 ] || [ "${1-}" != --only ] || [ "${2-}" != "$expected" ]; then + echo "unexpected capture runner arguments: $*" >&2 + exit 64 +fi +python3 - <<'PY' +import json, os, pathlib +root = pathlib.Path(os.environ['FLYOVER_EXPORT_DIRECTORY']) +profiles = os.environ['FLYOVER_EXPORT_PROFILES'].split(',') +images = [] +paths = {} +for index, profile in enumerate(profiles, 1): + relative = f'images/screen-0001/variant-0001/{profile}.png' + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'PNG') + paths[profile] = relative + images.append({'screenID': 'screen', 'variantID': 'default', 'profileID': profile, + 'relativePath': relative, 'pointWidth': 1, 'pointHeight': 1, + 'pixelWidth': 3, 'pixelHeight': 3, 'scale': 3, + 'captureExtent': 'viewport'}) +manifest = { + 'schemaVersion': 1, + 'application': {'id': 'where', 'title': 'Where'}, + 'build': { + 'commit': os.environ['FLYOVER_EXPORT_COMMIT'], + 'dirty': os.environ.get('FLYOVER_EXPORT_DIRTY') == 'true', + 'branch': os.environ.get('FLYOVER_EXPORT_BRANCH') or None, + 'generatedAt': os.environ['FLYOVER_EXPORT_GENERATED_AT'], + 'xcodeVersion': os.environ['FLYOVER_EXPORT_XCODE_VERSION'], + 'simulatorDevice': os.environ['FLYOVER_EXPORT_SIMULATOR_DEVICE'], + 'simulatorOS': os.environ['FLYOVER_EXPORT_SIMULATOR_OS'], + }, + 'profiles': [{ + 'id': profile, + 'title': profile, + 'device': 'phone', + 'orientation': 'portrait', + 'colorScheme': 'dark' if profile == 'phone-dark' else 'light', + 'dynamicType': 'large', + 'contrast': 'standard', + 'layoutDirection': 'left-to-right', + 'legibilityWeight': 'regular', + 'snapshotType': 'standard', + } for profile in profiles], + 'canvas': { + 'size': {'width': 500, 'height': 700}, + 'initialFitSize': {'width': 500, 'height': 700}, + 'groupFrames': [{ + 'id': 'group', + 'frame': {'x': 0, 'y': 0, 'width': 500, 'height': 700}, + }], + 'depthBandFrames': [{ + 'groupID': 'group', + 'kind': 'route', + 'depth': 0, + 'frame': {'x': 20, 'y': 20, 'width': 460, 'height': 660}, + }], + 'screenFrames': [{ + 'id': 'screen', + 'frame': {'x': 50, 'y': 50, 'width': 300, 'height': 650}, + }], + 'connectors': [], + }, + 'groups': [{ + 'id': 'group', + 'title': 'Group', + 'order': 0, + 'rootScreenID': 'screen', + 'screenIDs': ['screen'], + }], + 'screens': [{ + 'id': 'screen', + 'title': 'Screen', + 'groupID': 'group', + 'groupOrder': 0, + 'screenOrder': 0, + 'viewport': {'kind': 'device'}, + 'navigationContainer': 'stack', + 'frame': {'x': 50, 'y': 50, 'width': 300, 'height': 650}, + 'variants': [{ + 'id': 'default', + 'title': 'Default', + 'captureExtent': 'viewport', + 'imagesByProfile': paths, + }], + 'incomingRouteIDs': [], + 'outgoingRouteIDs': [], + }], + 'routes': [], + 'images': images, +} +if os.environ.get('FLYOVER_INVALID_MANIFEST') == 'missing-application': + del manifest['application'] +data = json.dumps(manifest, sort_keys=True, indent=2) +(root / 'manifest.json').write_text(data) +(root / 'manifest.js').write_text('window.FLYOVER_MANIFEST = ' + data + ';\n') +PY +if [ -n "${FLYOVER_RACE_DESTINATION:-}" ]; then + mkdir -p "$FLYOVER_RACE_DESTINATION" + printf '%s\n' retained >"$FLYOVER_RACE_DESTINATION/retained" +fi +if [ -n "${FLYOVER_RACE_SYMLINK_DESTINATION:-}" ]; then + ln -s "$FLYOVER_RACE_SYMLINK_DESTINATION.missing" "$FLYOVER_RACE_SYMLINK_DESTINATION" +fi +if [ -n "${FLYOVER_STALE_BACKUP_DESTINATION:-}" ]; then + stale_parent="$(dirname "$FLYOVER_STALE_BACKUP_DESTINATION")" + stale_name="$(basename "$FLYOVER_STALE_BACKUP_DESTINATION")" + stale="$stale_parent/.flyover-previous.$stale_name.$PPID" + mkdir -p "$stale" + printf '%s\n' retained >"$stale/retained" +fi +RUNNER +chmod +x "$SUCCESS_RUNNER" + +FAILURE_RUNNER="$TEMP/failure-runner" +cat >"$FAILURE_RUNNER" <<'RUNNER' +#!/bin/bash +exit 19 +RUNNER +chmod +x "$FAILURE_RUNNER" + +"$ROOT/flyover" --help | grep -q 'flyover export' || fail "export help is incomplete" +"$ROOT/flyover" --help | grep -q 'flyover preview' || fail "preview help is incomplete" +"$ROOT/flyover" export --help | grep -q 'phone-voiceover' \ + || fail "export help does not list the supported profiles" +"$ROOT/flyover" export --help | grep -q 'defaults are phone-light and phone-dark' \ + || fail "export help does not list both default profiles" +"$ROOT/flyover" preview --help | grep -q 'no authentication or TLS' \ + || fail "preview help does not explain LAN exposure" +expect_failure "$ROOT/flyover" export --profile system +grep -q "unknown profile" "$TEMP/stderr" || fail "unknown profile error is unclear" +expect_failure "$ROOT/flyover" export --output +expect_failure "$ROOT/flyover" export --profile +expect_failure "$ROOT/flyover" export --output / +expect_failure "$ROOT/flyover" export --output "$HOME" +expect_failure "$ROOT/flyover" export --output "$ROOT" +expect_failure "$ROOT/flyover" preview --output +expect_failure "$ROOT/flyover" preview --port +expect_failure "$ROOT/flyover" preview --port nope +expect_failure "$ROOT/flyover" preview --port -1 +expect_failure "$ROOT/flyover" preview --port 65536 +expect_failure "$ROOT/flyover" preview --profile phone-light +expect_failure "$ROOT/flyover" preview --output "$TEMP/missing" +grep -q "Run ./flyover export first" "$TEMP/stderr" \ + || fail "missing-preview error does not name the next action" +printf '%s\n' not-a-directory >"$TEMP/preview-file" +expect_failure "$ROOT/flyover" preview --output "$TEMP/preview-file" +grep -q "not a directory" "$TEMP/stderr" \ + || fail "preview-file error is unclear" + +ln -s / "$TEMP/root-alias" +expect_failure "$ROOT/flyover" export --output "$TEMP/root-alias" +grep -q "filesystem root" "$TEMP/stderr" || fail "root alias was not rejected as root" +ln -s "$HOME" "$TEMP/home-alias" +expect_failure "$ROOT/flyover" export --output "$TEMP/home-alias" +grep -q "home directory" "$TEMP/stderr" || fail "home alias was not rejected as home" +ln -s "$ROOT" "$TEMP/workspace-alias" +expect_failure "$ROOT/flyover" export --output "$TEMP/workspace-alias" +grep -q "workspace root" "$TEMP/stderr" || fail "workspace alias was not rejected as workspace" + +UNMARKED="$TEMP/unmarked" +mkdir -p "$UNMARKED" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests "$ROOT/flyover" export --output "$UNMARKED" +grep -q "unmarked" "$TEMP/stderr" || fail "unmarked-directory error is unclear" + +UNSUPPORTED_MARKER="$TEMP/unsupported-marker" +mkdir -p "$UNSUPPORTED_MARKER" +printf '%s\n' schemaVersion=2 >"$UNSUPPORTED_MARKER/.flyover-generated" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export --output "$UNSUPPORTED_MARKER" +grep -q "unsupported marker" "$TEMP/stderr" \ + || fail "unsupported generated marker was not rejected" + +SYMLINK_MARKER="$TEMP/symlink-marker" +mkdir -p "$SYMLINK_MARKER" +printf '%s\n' schemaVersion=1 >"$TEMP/marker-target" +ln -s "$TEMP/marker-target" "$SYMLINK_MARKER/.flyover-generated" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests "$ROOT/flyover" export --output "$SYMLINK_MARKER" +grep -q "symbolic-link marker" "$TEMP/stderr" \ + || fail "symbolic-link generated marker was not rejected" + +RACED="$TEMP/raced" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests FLYOVER_RACE_DESTINATION="$RACED" \ + "$ROOT/flyover" export --output "$RACED" +grep -q "unmarked" "$TEMP/stderr" || fail "publish-time destination change was not rejected" +[ -f "$RACED/retained" ] || fail "publish-time destination change was deleted" + +RACED_SYMLINK="$TEMP/raced-symlink" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + FLYOVER_RACE_SYMLINK_DESTINATION="$RACED_SYMLINK" \ + "$ROOT/flyover" export --output "$RACED_SYMLINK" +grep -q "non-directory" "$TEMP/stderr" \ + || fail "publish-time dangling symlink was not rejected" +[ -L "$RACED_SYMLINK" ] || fail "publish-time dangling symlink was deleted" + +CALLER="$TEMP/caller" +mkdir -p "$CALLER" +( + cd "$CALLER" + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export >/dev/null +) +[ -f "$CALLER/.build/flyover/where/.flyover-generated" ] \ + || fail "default output path did not resolve from the caller directory" +python3 - "$CALLER/.build/flyover/where/manifest.json" <<'PY' +import json, pathlib, sys +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +profiles = [item['id'] for item in manifest['profiles']] +if profiles != ['phone-light', 'phone-dark']: + raise SystemExit(f'default profiles are wrong: {profiles}') +if len(manifest['images']) != 2: + raise SystemExit(f'default image count is wrong: {len(manifest["images"])}') +PY + +OUTPUT="$TEMP/output" +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export --output "$OUTPUT" \ + --profile phone-dark --profile phone-light --profile phone-dark >/dev/null +python3 - "$OUTPUT" <<'PY' +import json, pathlib, stat, sys +root = pathlib.Path(sys.argv[1]) +profiles = [item['id'] for item in json.loads((root / 'manifest.json').read_text())['profiles']] +if profiles != ['phone-dark', 'phone-light']: + raise SystemExit(f'profile order or deduplication is wrong: {profiles}') +bad_directories = [path for path in [root, *root.rglob('*')] + if path.is_dir() and stat.S_IMODE(path.stat().st_mode) != 0o755] +bad_files = [path for path in root.rglob('*') + if path.is_file() and stat.S_IMODE(path.stat().st_mode) != 0o644] +if bad_directories or bad_files: + raise SystemExit(f'unsafe published modes: directories={bad_directories}, files={bad_files}') +PY + +PREVIEW_ARGUMENTS="$TEMP/preview-arguments" +PREVIEW_ENVIRONMENT="$TEMP/preview-environment" +PREVIEW_RUNNER="$TEMP/preview-runner" +cat >"$PREVIEW_RUNNER" <<'RUNNER' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$@" >"$FLYOVER_PREVIEW_ARGUMENTS" +printf '%s\n' "${PYTHONDONTWRITEBYTECODE-}" >"$FLYOVER_PREVIEW_ENVIRONMENT" +RUNNER +chmod +x "$PREVIEW_RUNNER" +FLYOVER_PREVIEW_PYTHON="$PREVIEW_RUNNER" \ + FLYOVER_PREVIEW_ARGUMENTS="$PREVIEW_ARGUMENTS" \ + FLYOVER_PREVIEW_ENVIRONMENT="$PREVIEW_ENVIRONMENT" \ + "$ROOT/flyover" preview --output "$OUTPUT" --lan --port 8080 +python3 - "$PREVIEW_ARGUMENTS" "$ROOT" "$OUTPUT" <<'PY' +import pathlib, sys +arguments = pathlib.Path(sys.argv[1]).read_text().splitlines() +expected = [ + f'{sys.argv[2]}/Tools/flyover_preview.py', + 'serve', + str(pathlib.Path(sys.argv[3]).resolve()), + '--port', + '8080', + '--lan', +] +if arguments != expected: + raise SystemExit(f'preview runner arguments are wrong: {arguments}') +PY + +( + cd "$CALLER" + FLYOVER_PREVIEW_PYTHON="$PREVIEW_RUNNER" \ + FLYOVER_PREVIEW_ARGUMENTS="$PREVIEW_ARGUMENTS" \ + FLYOVER_PREVIEW_ENVIRONMENT="$PREVIEW_ENVIRONMENT" \ + "$ROOT/flyover" preview --port 0 +) +python3 - "$PREVIEW_ARGUMENTS" "$ROOT" "$CALLER/.build/flyover/where" <<'PY' +import pathlib, sys +arguments = pathlib.Path(sys.argv[1]).read_text().splitlines() +expected = [ + f'{sys.argv[2]}/Tools/flyover_preview.py', + 'serve', + str(pathlib.Path(sys.argv[3]).resolve()), + '--port', + '0', +] +if arguments != expected: + raise SystemExit(f'default preview arguments are wrong: {arguments}') +PY +[ "$(< "$PREVIEW_ENVIRONMENT")" = 1 ] \ + || fail "preview Python can write bytecode into the source tree" + +expect_failure "$ROOT/flyover" preview --output "$UNMARKED" +grep -q "not a generated Flyover atlas" "$TEMP/stderr" \ + || fail "preview accepted an unmarked directory" + +PREVIEW_WITH_SPACES="$TEMP/preview output" +cp -R "$OUTPUT" "$PREVIEW_WITH_SPACES" +python3 - "$ROOT/flyover" "$PREVIEW_WITH_SPACES" <<'PY' +import http.client +import os +import pathlib +import re +import selectors +import signal +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +command, directory = sys.argv[1:] +(pathlib.Path(directory) / 'http:' / 'remote.example').mkdir(parents=True) +(pathlib.Path(directory) / 'http:' / 'remote.example' / 'manifest.json').write_text( + 'not the atlas manifest' +) +process = subprocess.Popen( + [command, 'preview', '--output', directory, '--port', '0'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, +) +assert process.stdout is not None +assert process.stderr is not None +selector = selectors.DefaultSelector() +selector.register(process.stdout, selectors.EVENT_READ) +selector.register(process.stderr, selectors.EVENT_READ) +chunks = [] +deadline = time.monotonic() + 10 +try: + while time.monotonic() < deadline and b'Press Ctrl-C' not in b''.join(chunks): + for key, _ in selector.select(timeout=max(0, deadline - time.monotonic())): + chunk = os.read(key.fileobj.fileno(), 4096) + if chunk: + chunks.append(chunk) + if process.poll() is not None: + break + output = b''.join(chunks).decode(errors='replace') + match = re.search(r'^Local:\s+(http://127\.0\.0\.1:\d+/)$', output, re.MULTILINE) + if match is None: + raise SystemExit(f'preview did not print a usable local URL:\n{output}') + if f'Flyover preview: {pathlib.Path(directory).resolve()}' not in output: + raise SystemExit(f'preview did not print the resolved directory:\n{output}') + + base_url = match.group(1) + if urllib.request.urlopen(base_url, timeout=5).status != 200: + raise SystemExit('preview index request failed') + manifest = urllib.request.urlopen(base_url + 'manifest.json', timeout=5).read() + if b'"schemaVersion": 1' not in manifest: + raise SystemExit('preview served an unexpected manifest') + image = urllib.request.urlopen( + base_url + 'images/screen-0001/variant-0001/phone-dark.png', + timeout=5, + ).read() + if image != b'PNG': + raise SystemExit('preview served unexpected image data') + + outside_file = pathlib.Path(directory).parent / 'outside-preview-file' + outside_file.write_bytes(b'SECRET') + image_path = ( + pathlib.Path(directory) + / 'images/screen-0001/variant-0001/phone-dark.png' + ) + image_path.unlink() + image_path.symlink_to(outside_file) + try: + urllib.request.urlopen( + base_url + 'images/screen-0001/variant-0001/phone-dark.png', + timeout=5, + ) + raise SystemExit('preview followed a replacement image symbolic link') + except urllib.error.HTTPError as error: + if error.code != 404: + raise + + original_root = pathlib.Path(directory) + moved_root = original_root.with_name(original_root.name + '-moved') + replacement_root = original_root.with_name(original_root.name + '-replacement') + original_root.rename(moved_root) + replacement_root.mkdir() + (replacement_root / 'manifest.json').write_bytes(b'SECRET') + original_root.symlink_to(replacement_root, target_is_directory=True) + pinned_manifest = urllib.request.urlopen( + base_url + 'manifest.json', + timeout=5, + ).read() + if b'"schemaVersion": 1' not in pinned_manifest or pinned_manifest == b'SECRET': + raise SystemExit('preview changed roots after server startup') + + try: + urllib.request.urlopen(base_url + 'assets/', timeout=5) + raise SystemExit('preview exposed a directory listing') + except urllib.error.HTTPError as error: + if error.code != 404: + raise + + port = int(base_url.rstrip('/').rsplit(':', 1)[1]) + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + connection.request('GET', '/%2e%2e/manifest.json') + response = connection.getresponse() + if response.status != 404: + raise SystemExit(f'preview accepted a traversal request: {response.status}') + response.read() + connection.close() + + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + connection.request('GET', 'http://remote.example/manifest.json') + response = connection.getresponse() + if response.status != 404: + raise SystemExit(f'preview accepted an absolute request target: {response.status}') + response.read() + connection.close() + + with socket.create_connection(('127.0.0.1', port), timeout=5) as client: + client.sendall( + b'GET http://[/manifest.json HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\nConnection: close\r\n\r\n' + ) + response_data = client.recv(4096) + if b' 404 ' not in response_data: + raise SystemExit('preview did not reject a malformed absolute request target') + + with socket.create_connection(('127.0.0.1', port), timeout=5) as client: + client.sendall( + b'GET /\x1b]0;untrusted\x07 HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\nConnection: close\r\n\r\n' + ) + response_data = client.recv(4096) + if b' 404 ' not in response_data: + raise SystemExit('preview did not reject the terminal-control request') +finally: + if process.poll() is None: + process.send_signal(signal.SIGINT) + try: + status = process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + status = process.wait(timeout=5) + stderr = process.stderr.read().decode(errors='replace') + if status != 0: + raise SystemExit(f'preview exited with {status}:\n{output}\n{stderr}') + if stderr: + raise SystemExit(f'preview logged untrusted request data:\n{stderr}') +PY + +INVALID_PREVIEW="$TEMP/invalid-preview" +cp -R "$OUTPUT" "$INVALID_PREVIEW" +printf '%s\n' schemaVersion=2 >"$INVALID_PREVIEW/.flyover-generated" +expect_failure "$ROOT/flyover" preview --output "$INVALID_PREVIEW" +grep -q "generated marker is unsupported" "$TEMP/stderr" \ + || fail "preview accepted an unsupported marker" + +INVALID_UTF8_PREVIEW="$TEMP/invalid-utf8-preview" +cp -R "$OUTPUT" "$INVALID_UTF8_PREVIEW" +printf '\377' >"$INVALID_UTF8_PREVIEW/.flyover-generated" +expect_failure "$ROOT/flyover" preview --output "$INVALID_UTF8_PREVIEW" +grep -q "could not read" "$TEMP/stderr" \ + || fail "preview did not report an invalid marker encoding" +if grep -q "Traceback" "$TEMP/stderr"; then + fail "preview printed a traceback for an invalid marker encoding" +fi + +SYMLINK_PREVIEW="$TEMP/symlink-preview" +cp -R "$OUTPUT" "$SYMLINK_PREVIEW" +ln -s "$TEMP/marker-target" "$SYMLINK_PREVIEW/leak" +expect_failure "$ROOT/flyover" preview --output "$SYMLINK_PREVIEW" +grep -q "contains a symbolic link" "$TEMP/stderr" \ + || fail "preview accepted a symbolic link" + +STANDARD_CACHE_PYTHON_BIN="$TEMP/standard-cache-python-bin" +mkdir -p "$STANDARD_CACHE_PYTHON_BIN" +cat >"$STANDARD_CACHE_PYTHON_BIN/python3" <<'PYTHON' +#!/bin/bash +set -euo pipefail +exec "$FLYOVER_REAL_PYTHON" -X pycache_prefix= "$@" +PYTHON +chmod +x "$STANDARD_CACHE_PYTHON_BIN/python3" +REAL_PYTHON="$(command -v python3)" + +CLEAN_REPO="$TEMP/clean-repo" +mkdir -p "$CLEAN_REPO/Shared/Flyover/Web/assets" +mkdir -p "$CLEAN_REPO/Tools" +cp "$ROOT/flyover" "$CLEAN_REPO/flyover" +cp "$ROOT/Tools/flyover_manifest.py" "$CLEAN_REPO/Tools/flyover_manifest.py" +cp "$ROOT/Tools/flyover_preview.py" "$CLEAN_REPO/Tools/flyover_preview.py" +cp "$ROOT/Shared/Flyover/Web/index.html" "$CLEAN_REPO/Shared/Flyover/Web/index.html" +cp "$ROOT/Shared/Flyover/Web/assets/app.js" "$CLEAN_REPO/Shared/Flyover/Web/assets/app.js" +cp "$ROOT/Shared/Flyover/Web/assets/styles.css" "$CLEAN_REPO/Shared/Flyover/Web/assets/styles.css" +chmod +x "$CLEAN_REPO/flyover" +git -C "$CLEAN_REPO" init -q +git -C "$CLEAN_REPO" add . +git -C "$CLEAN_REPO" -c user.name=Flyover -c user.email=flyover@example.com \ + -c commit.gpgsign=false commit -qm fixture +PATH="$STANDARD_CACHE_PYTHON_BIN:$PATH" FLYOVER_REAL_PYTHON="$REAL_PYTHON" \ + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null +PATH="$STANDARD_CACHE_PYTHON_BIN:$PATH" FLYOVER_REAL_PYTHON="$REAL_PYTHON" \ + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null +python3 - "$CLEAN_REPO/site/manifest.json" <<'PY' +import json, pathlib, sys +dirty = json.loads(pathlib.Path(sys.argv[1]).read_text())['build']['dirty'] +if dirty: + raise SystemExit('staging a clean in-repository export marked the source dirty') +PY +[ ! -e "$CLEAN_REPO/Tools/__pycache__" ] \ + || fail "Flyover Python tooling wrote bytecode into the source tree" + +FAILING_GIT_BIN="$TEMP/failing-git-bin" +mkdir -p "$FAILING_GIT_BIN" +cat >"$FAILING_GIT_BIN/git" <<'GIT' +#!/bin/bash +for argument in "$@"; do + if [ "$argument" = status ]; then + exit 71 + fi +done +exec "$FLYOVER_REAL_GIT" "$@" +GIT +chmod +x "$FAILING_GIT_BIN/git" +expect_failure env PATH="$FAILING_GIT_BIN:$PATH" FLYOVER_REAL_GIT="$(command -v git)" \ + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/status-failure" +grep -q "could not read the Git working-tree status" "$TEMP/stderr" \ + || fail "a Git status failure was treated as a clean source tree" + +printf '%s\n' retained-before-invalid >"$OUTPUT/retained-before-invalid" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests FLYOVER_INVALID_MANIFEST=missing-application \ + "$ROOT/flyover" export --output "$OUTPUT" +grep -q "manifest.application is not an object" "$TEMP/stderr" \ + || fail "an incomplete browser manifest did not report its missing section" +[ -f "$OUTPUT/retained-before-invalid" ] \ + || fail "an incomplete browser manifest replaced the last successful output" + +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + FLYOVER_STALE_BACKUP_DESTINATION="$OUTPUT" \ + "$ROOT/flyover" export --output "$OUTPUT" >/dev/null +STALE_BACKUP="$(find "$TEMP" -maxdepth 1 -type d \ + -name '.flyover-previous.output.*' -print -quit)" +[ -n "$STALE_BACKUP" ] && [ -f "$STALE_BACKUP/retained" ] \ + || fail "replacement removed a backup directory that it did not create" +rm -rf "$STALE_BACKUP" +if find "$TEMP" -maxdepth 1 -name '.flyover-replacement.*' | grep -q .; then + fail "a successful export left its replacement directory" +fi + +printf '%s\n' old >"$OUTPUT/old-file" +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export --output "$OUTPUT" >/dev/null +[ ! -e "$OUTPUT/old-file" ] || fail "a marked directory was not replaced" + +printf '%s\n' retained >"$OUTPUT/retained" +expect_failure env FLYOVER_CAPTURE_RUNNER="$FAILURE_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests "$ROOT/flyover" export --output "$OUTPUT" +[ -f "$OUTPUT/retained" ] || fail "a failed export replaced the last successful output" +if find "$TEMP" -maxdepth 1 -name '.flyover-staging.*' | grep -q .; then + fail "a failed export left a staging directory" +fi + +if grep -R -E 'fetch\(|https?://' "$ROOT/Shared/Flyover/Web" \ + | grep -v 'http://www.w3.org/2000/svg' >/dev/null; then + fail "the web shell contains a network dependency" +fi + +python3 - "$ROOT/Shared/Flyover/Web/assets/styles.css" \ + "$ROOT/Shared/Flyover/Web/assets/app.js" <<'PY' +import pathlib +import re +import sys + +styles = pathlib.Path(sys.argv[1]).read_text() +javascript = pathlib.Path(sys.argv[2]).read_text() +properties_by_selector = {} +for selector_group, declaration_group in re.findall(r'([^{}]+)\{([^{}]*)\}', styles): + declarations = {} + for declaration in declaration_group.split(';'): + if ':' not in declaration: + continue + name, value = declaration.split(':', 1) + declarations[name.strip()] = value.strip() + for selector in selector_group.split(','): + properties_by_selector.setdefault(selector.strip(), {}).update(declarations) + +def require_properties(selector, expected): + actual = properties_by_selector.get(selector, {}) + for name, value in expected.items(): + if actual.get(name) != value: + raise SystemExit(f'{selector} must set {name}: {value}') + +for selector in ('#app', '#app > dialog'): + require_properties(selector, { + '-webkit-user-select': 'none', + 'user-select': 'none', + }) +for selector in ( + '#app input', + '#app textarea', + '#app [contenteditable="true"]', + '#app .selectable-text', +): + require_properties(selector, { + '-webkit-user-select': 'text', + 'user-select': 'text', + }) +require_properties('#app img', {'-webkit-user-drag': 'none'}) + +factory_start = javascript.index( + ' function screenImage(screen, className = "", eager = false, fullResolution = false) {' +) +factory_end = javascript.index('\n\n function captureViewportSize(screen) {', factory_start) +factory = javascript[factory_start:factory_end] +if 'image.draggable = false;' not in factory: + raise SystemExit('screen images must disable native dragging') +if 'fullResolution ? imagePath(screen) : thumbnailPath(screen)' not in factory: + raise SystemExit('atlas images must use thumbnails while allowing full-resolution inspection') +if 'const fullImage = screenImage(screen, "", true, true);' not in javascript: + raise SystemExit('the inspector must request the full-resolution image') +require_properties('.bottom-dock', {'justify-content': 'safe center'}) + +for fragment in ( + 'class="error selectable-text"', + 'element("dd", "selectable-text", value)', + 'element("h2", "selectable-text", screen.title)', + 'element("h3", "selectable-text", screen.title)', +): + if fragment not in javascript: + raise SystemExit('missing intentional text-selection surface: ' + fragment) +PY + +JSC="/System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Helpers/jsc" +[ -x "$JSC" ] || fail "JavaScriptCore is unavailable" +python3 - "$ROOT/Shared/Flyover/Web/assets/app.js" "$TEMP/residency-test.js" <<'PY' +import pathlib +import sys +import textwrap + +source = pathlib.Path(sys.argv[1]).read_text() +settings_start_marker = ' const targetResidentImageCount = ' +settings_end_marker = '\n let inspectorResizeObserver' +image_path_start_marker = ' function screenVariant(screen) {' +image_path_end_marker = '\n\n function connectedRoutes(screen) {' +image_factory_start_marker = ' function screenImage(screen, className = "", eager = false, fullResolution = false) {' +image_factory_end_marker = '\n\n function captureViewportSize(screen) {' +start_marker = ' function residentScreenIDs(candidates) {' +end_marker = '\n\n function installCanvasPinch(viewport) {' +fit_start_marker = ' function setZoom(next) {' +fit_end_marker = '\n\n function fitAll(behavior = "smooth") {' +fit_first_start_marker = ' function fitFirstGroup() {' +fit_first_end_marker = '\n\n let canvasNavigationFrame' +settings_start = source.index(settings_start_marker) +settings_end = source.index(settings_end_marker, settings_start) +image_path_start = source.index(image_path_start_marker) +image_path_end = source.index(image_path_end_marker, image_path_start) +image_factory_start = source.index(image_factory_start_marker) +image_factory_end = source.index(image_factory_end_marker, image_factory_start) +start = source.index(start_marker) +end = source.index(end_marker, start) +fit_start = source.index(fit_start_marker) +fit_end = source.index(fit_end_marker, fit_start) +fit_first_start = source.index(fit_first_start_marker) +fit_first_end = source.index(fit_first_end_marker, fit_first_start) +settings = textwrap.dedent(source[settings_start:settings_end]) +image_paths = textwrap.dedent(source[image_path_start:image_path_end]) +image_factory = textwrap.dedent(source[image_factory_start:image_factory_end]) +residency = textwrap.dedent(source[start:end]) +fit_functions = textwrap.dedent(source[fit_start:fit_end]) +fit_first_group = textwrap.dedent(source[fit_first_start:fit_first_end]) +test = '\n'.join(( + settings, + image_paths, + image_factory, + residency, + fit_functions, + fit_first_group, + r''' +let document; +let manifest; +let state; +let screenByID; +let selectedVariants; +let imageByKey; +const window = { + matchMedia: () => ({ matches: true }), +}; + +function requestAnimationFrame(callback) { + callback(); + return 1; +} + +function element(tag, className) { + return { + tag, + className, + dataset: {}, + getAttribute(name) { return this[name] || null; }, + removeAttribute(name) { delete this[name]; }, + }; +} + +function matchesFilters() { + return true; +} + +function applyZoom() {} +function captureCanvasPosition() {} + +function candidate(id, isVisible, imagePixels) { + return { screen: { id }, isVisible, imagePixels }; +} + +function expect(condition, message) { + if (!condition) throw new Error(message); +} + +function expectIDs(actual, expected, message) { + const actualIDs = Array.from(actual); + if (JSON.stringify(actualIDs) !== JSON.stringify(expected)) { + throw new Error(message + ': ' + JSON.stringify(actualIDs)); + } +} + +const imageScreen = { + id: 'screen', + title: 'Screen', + variants: [{ + id: 'default', + title: 'Default', + captureExtent: 'viewport', + imagesByProfile: { 'phone-light': 'images/full.png' }, + }], +}; +selectedVariants = new Map([['screen', 'default']]); +state = { profile: 'phone-light' }; +imageByKey = new Map([[ + imageKey('screen', 'default', 'phone-light'), + { relativePath: 'images/full.png', thumbnailRelativePath: 'images/thumbnail.png' }, +]]); +const cardImage = screenImage(imageScreen); +expect(cardImage.dataset.src === 'images/thumbnail.png', 'cards must use the generated thumbnail'); +const inspectorImage = screenImage(imageScreen, '', true, true); +expect(inspectorImage.src === 'images/full.png', 'the inspector must use the full-resolution PNG'); +imageByKey.get(imageKey('screen', 'default', 'phone-light')).thumbnailRelativePath = null; +expect( + screenImage(imageScreen).dataset.src === 'images/full.png', + 'older manifests without thumbnails must fall back to the full-resolution PNG', +); +imageMetadata = screen => screen.imageMetadata; +expect( + screenImagePixels({ + imageMetadata: { + pixelWidth: 2_500, + pixelHeight: 8_000, + thumbnailRelativePath: 'images/thumbnail.png', + thumbnailPixelWidth: 470, + thumbnailPixelHeight: 1_024, + }, + }) === 481_280, + 'residency budgets must use thumbnail dimensions', +); +expect( + screenImagePixels({ imageMetadata: { pixelWidth: 2_500, pixelHeight: 8_000 } }) === 20_000_000, + 'older manifests must budget their full-resolution fallback', +); + +const visible = Array.from( + { length: 20 }, + (_, index) => candidate('visible-' + index, true, 5_000_000), +); +expectIDs( + residentScreenIDs(visible), + visible.map(item => item.screen.id), + 'all visible screens must remain resident above both budgets', +); + +const mixed = [ + ...Array.from({ length: 10 }, (_, index) => candidate('nearby-' + index, false, 1_000_000)), + candidate('visible-a', true, 1_000_000), + candidate('visible-b', true, 1_000_000), +]; +expectIDs( + residentScreenIDs(mixed), + ['visible-a', 'visible-b', 'nearby-0', 'nearby-1', 'nearby-2', 'nearby-3'], + 'offscreen prefetch must stop at the resident count budget', +); + +const largeNearby = Array.from( + { length: 8 }, + (_, index) => candidate('large-' + index, false, 5_000_000), +); +expectIDs( + residentScreenIDs(largeNearby), + ['large-0', 'large-1', 'large-2', 'large-3'], + 'offscreen prefetch must stop at the resident pixel budget', +); + +function imageContainer(screenID, initiallyResident = false) { + const attributes = new Map(); + if (initiallyResident) attributes.set('src', screenID + '.png'); + const image = { + dataset: { src: screenID + '.png' }, + loading: 'lazy', + getAttribute: name => attributes.get(name) || null, + removeAttribute: name => attributes.delete(name), + set src(value) { attributes.set('src', value); }, + get src() { return attributes.get('src'); }, + }; + return { + dataset: { screenId: screenID }, + image, + querySelector: () => image, + }; +} + +const canvasScreens = Array.from({ length: 20 }, (_, index) => ({ + id: 'canvas-' + index, + frame: { x: 10, y: 10, width: 20, height: 20 }, + imageMetadata: { pixelWidth: 2_500, pixelHeight: 2_000 }, +})); +const canvasContainers = canvasScreens.map(screen => imageContainer(screen.id)); +const canvasViewport = { + clientHeight: 100, + clientWidth: 100, + scrollLeft: 0, + scrollTop: 0, +}; +manifest = { screens: canvasScreens }; +state = { screen: null, view: 'canvas', zoom: 1 }; +document = { + getElementById: id => id === 'canvas-viewport' ? canvasViewport : null, + querySelectorAll: selector => selector === '.screen-card' ? canvasContainers : [], +}; +updateCanvasImageResidency(); +expect( + canvasContainers.every(container => container.image.getAttribute('src') !== null), + 'all 20 visible canvas cards must receive an image source', +); +expect( + canvasContainers.every(container => container.image.loading === 'eager'), + 'visible canvas images must load eagerly', +); + +const visibleListScreens = Array.from({ length: 20 }, (_, index) => ({ + id: 'list-visible-' + index, + imageMetadata: { pixelWidth: 2_500, pixelHeight: 2_000 }, + screenOrder: index, +})); +const nearbyListScreens = Array.from({ length: 10 }, (_, index) => ({ + id: 'list-nearby-' + index, + imageMetadata: { pixelWidth: 1_000, pixelHeight: 1_000 }, + screenOrder: visibleListScreens.length + index, +})); +const listRows = [ + ...visibleListScreens.map(screen => ({ + ...imageContainer(screen.id), + getBoundingClientRect: () => ({ top: 10, bottom: 90 }), + })), + ...nearbyListScreens.map((screen, index) => ({ + ...imageContainer(screen.id, true), + getBoundingClientRect: () => ({ top: 101 + index, bottom: 111 + index }), + })), +]; +const listViewport = { + getBoundingClientRect: () => ({ top: 0, bottom: 100 }), +}; +screenByID = new Map( + [...visibleListScreens, ...nearbyListScreens].map(screen => [screen.id, screen]), +); +state = { screen: null, view: 'list' }; +document = { + querySelector: selector => selector === '.list-view' ? listViewport : null, + querySelectorAll: selector => selector === '.list-row' ? listRows : [], +}; +updateListImageResidency(); +expect( + listRows.slice(0, 20).every(row => row.image.getAttribute('src') !== null), + 'all 20 visible list rows must receive an image source', +); +expect( + listRows.slice(20).every(row => row.image.getAttribute('src') === null), + 'offscreen list preloads must yield when visible rows exceed the targets', +); + +let residencyRefreshCount = 0; +const fitViewport = { + clientHeight: 600, + clientWidth: 800, + scrollTo: () => {}, +}; +state = { canvas: {}, zoom: 1 }; +document = { + getElementById: id => id === 'canvas-viewport' ? fitViewport : null, +}; +updateCanvasImageResidency = () => { residencyRefreshCount += 1; }; +fitFrame({ x: 0, y: 0, width: 1_600, height: 1_200 }, 'auto'); +expect( + residencyRefreshCount === 1, + 'fitting the canvas must refresh image residency after changing zoom', +); + +setZoom(-1); +expect(state.zoom === minimumManualZoom, 'manual zoom must retain its 10% floor'); +setZoom(9); +expect(state.zoom === maximumZoom, 'manual zoom must retain its 150% ceiling'); + +for (const width of [320, 390]) { + fitViewport.clientWidth = width; + fitViewport.clientHeight = 700; + state.zoom = 1; + fitFrame({ x: 0, y: 0, width: 8_260, height: 4_000 }, 'auto'); + const availableWidth = width - 64; + expect( + 8_260 * state.zoom <= availableWidth + 0.001, + 'Fit All must fit the canvas at ' + width + 'px', + ); + const fittedZoom = state.zoom; + setZoom(state.zoom - 0.1); + expect( + state.zoom === fittedZoom, + 'zooming out from a sub-10% fit must not increase zoom', + ); + + manifest = { canvas: { initialFitSize: { width: 2_500 } } }; + updateCanvasNavigation = () => {}; + fitFirstGroup(); + expect( + 2_500 * state.zoom <= width - 32 + 0.001, + 'the initial group must fit at ' + width + 'px', + ); +} +''', +)) +pathlib.Path(sys.argv[2]).write_text(test) +PY +"$JSC" "$TEMP/residency-test.js" \ + || fail "the web thumbnail residency policy is incorrect" diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js new file mode 100644 index 000000000..d9bfae957 --- /dev/null +++ b/Shared/Flyover/Web/assets/app.js @@ -0,0 +1,1916 @@ +(() => { + "use strict"; + + const root = document.getElementById("app"); + const manifest = window.FLYOVER_MANIFEST; + if (!manifest || manifest.schemaVersion !== 1) { + root.innerHTML = '

Manifest error

Flyover cannot open this atlas

This site requires manifest schema version 1.

'; + return; + } + + const screenByID = new Map(manifest.screens.map(screen => [screen.id, screen])); + const groupByID = new Map(manifest.groups.map(group => [group.id, group])); + const routeByID = new Map(manifest.routes.map(route => [route.id, route])); + const profileByID = new Map(manifest.profiles.map(profile => [profile.id, profile])); + const imageByKey = new Map(manifest.images.map(image => [ + imageKey(image.screenID, image.variantID, image.profileID), + image, + ])); + const defaultView = "canvas"; + const defaultProfile = manifest.profiles[0]?.id; + const targetResidentImageCount = 6; + const targetResidentPixelCount = 24_000_000; + const minimumManualZoom = 0.1; + const maximumZoom = 1.5; + let inspectorResizeObserver = null; + const selectedVariants = new Map(manifest.screens.map(screen => [screen.id, screen.variants[0]?.id])); + const state = { + view: defaultView, + profile: defaultProfile, + screen: null, + zoom: 1, + group: manifest.groups[0]?.id, + routeFocus: null, + inspectorScale: "fit", + inspectorDetails: false, + panel: null, + panelTrigger: null, + commandQuery: "", + pendingCanvasAction: null, + pendingCanvasPosition: null, + pendingFocusKey: null, + inspectorReturnFocusKey: null, + canvas: { + initialized: false, + scrollLeft: 0, + scrollTop: 0, + viewportWidth: 0, + viewportHeight: 0, + }, + list: { + scrollTop: 0, + }, + filters: { + group: "all", + extent: "all", + routes: "all", + }, + }; + + const iconPaths = { + canvas: "M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z", + close: "M6 6l12 12M18 6 6 18", + external: "M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6", + filter: "M4 6h16M7 12h10M10 18h4", + fit: "M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5", + info: "M12 11v6M12 7h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", + left: "m15 18-6-6 6-6", + list: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01", + map: "m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3V6Zm6-3v15m6-12v15", + minus: "M5 12h14", + plus: "M12 5v14M5 12h14", + right: "m9 18 6-6-6-6", + route: "M5 6h7a4 4 0 0 1 4 4v8m-4-4 4 4 4-4", + search: "m21 21-4.35-4.35M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z", + }; + + function element(tag, className, text) { + const value = document.createElement(tag); + if (className) value.className = className; + if (text !== undefined) value.textContent = text; + return value; + } + + function quantity(count, singular) { + return count + " " + (count === 1 ? singular : singular + "s"); + } + + function icon(name) { + const namespace = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(namespace, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.classList.add("icon"); + const path = document.createElementNS(namespace, "path"); + path.setAttribute("d", iconPaths[name]); + svg.append(path); + return svg; + } + + function iconButton(iconName, label, className = "icon-button", visibleLabel = false) { + const button = element("button", className); + button.type = "button"; + button.setAttribute("aria-label", label); + button.title = label; + button.append(icon(iconName)); + if (visibleLabel) button.append(element("span", "button-label", label)); + return button; + } + + function screenVariant(screen) { + const id = selectedVariants.get(screen.id); + return screen.variants.find(variant => variant.id === id) || screen.variants[0]; + } + + function imagePath(screen) { + return screenVariant(screen)?.imagesByProfile[state.profile] || ""; + } + + function imageKey(screenID, variantID, profileID) { + return JSON.stringify([screenID, variantID, profileID]); + } + + function imageMetadata(screen) { + const variant = screenVariant(screen); + return imageByKey.get(imageKey(screen.id, variant?.id, state.profile)); + } + + function thumbnailPath(screen) { + return imageMetadata(screen)?.thumbnailRelativePath || imagePath(screen); + } + + function connectedRoutes(screen) { + return [...screen.incomingRouteIDs, ...screen.outgoingRouteIDs] + .map(id => routeByID.get(id)) + .filter(Boolean); + } + + function searchableText(screen) { + const group = groupByID.get(screen.groupID); + const routeText = connectedRoutes(screen).flatMap(route => { + const source = screenByID.get(route.sourceScreenID); + const destination = screenByID.get(route.destinationScreenID); + return [route.label, route.kind, source?.title, destination?.title]; + }); + return [group?.title, screen.title, ...screen.variants.map(variant => variant.title), ...routeText] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + } + + function matchesFilters(screen) { + if (state.filters.group !== "all" && screen.groupID !== state.filters.group) return false; + if (state.filters.extent !== "all" && screenVariant(screen)?.captureExtent !== state.filters.extent) { + return false; + } + if (state.filters.routes === "incoming" && screen.incomingRouteIDs.length === 0) return false; + if (state.filters.routes === "outgoing" && screen.outgoingRouteIDs.length === 0) return false; + if (state.filters.routes === "linked" + && screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length === 0) return false; + if (state.filters.routes === "unlinked" + && screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length !== 0) return false; + return true; + } + + function visibleScreenIDs() { + return new Set(manifest.screens.filter(matchesFilters).map(screen => screen.id)); + } + + function parseHash() { + const previousScreenID = state.screen; + const values = new URLSearchParams(location.hash.replace(/^#/, "")); + const view = values.get("view"); + const profile = values.get("profile"); + const screenID = values.get("screen"); + const variantID = values.get("variant"); + state.view = view === "canvas" || view === "list" ? view : defaultView; + state.profile = profileByID.has(profile) ? profile : defaultProfile; + if (screenByID.has(screenID)) { + state.screen = screenID; + const screen = screenByID.get(screenID); + const selectedVariant = screen.variants.some(variant => variant.id === variantID) + ? variantID : screen.variants[0]?.id; + selectedVariants.set(screenID, selectedVariant); + } else { + state.screen = null; + if (previousScreenID) { + state.pendingFocusKey = state.inspectorReturnFocusKey || "atlas-screen-" + previousScreenID; + } + } + } + + function writeHash() { + const values = new URLSearchParams(); + values.set("view", state.view); + values.set("profile", state.profile); + if (state.screen) { + values.set("screen", state.screen); + values.set("variant", selectedVariants.get(state.screen)); + } + const next = "#" + values; + if (location.hash === next) return false; + location.hash = next; + return true; + } + + function renderOrNavigate() { + if (!writeHash()) render(); + } + + function chooseView(view) { + state.view = view; + state.panel = null; + if (view === "canvas" && !state.canvas.initialized) state.pendingCanvasAction = "fit-initial"; + renderOrNavigate(); + } + + function chooseProfile(profile) { + state.profile = profile; + renderOrNavigate(); + } + + function chooseVariant(screen, variantID, updateHistory = false) { + selectedVariants.set(screen.id, variantID); + if (state.screen === screen.id || updateHistory) { + state.screen = screen.id; + renderOrNavigate(); + } else { + render(); + } + } + + function openScreen(screenID, preserveVariant = false) { + const screen = screenByID.get(screenID); + if (!screen) return; + if (!state.screen) state.inspectorReturnFocusKey = "atlas-screen-" + screenID; + if (!preserveVariant) selectedVariants.set(screen.id, screen.variants[0]?.id); + state.screen = screen.id; + state.routeFocus = screen.id; + state.inspectorScale = "fit"; + state.inspectorDetails = false; + state.panel = null; + renderOrNavigate(); + } + + function neighboringScreen(offset) { + const index = manifest.screens.findIndex(screen => screen.id === state.screen); + if (index < 0) return null; + const next = (index + offset + manifest.screens.length) % manifest.screens.length; + return manifest.screens[next]; + } + + function formatGeneratedAt(value) { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + } + + function metadataRow(label, value) { + const row = element("div", "metadata-row"); + row.append(element("dt", "", label), element("dd", "selectable-text", value)); + return row; + } + + function profileSelect(className = "", focusKey = "profile") { + const select = element("select", className); + select.setAttribute("aria-label", "Capture profile"); + select.dataset.focusKey = focusKey; + for (const item of manifest.profiles) { + const option = element("option", "", item.title); + option.value = item.id; + option.selected = item.id === state.profile; + select.append(option); + } + select.addEventListener("change", () => chooseProfile(select.value)); + return select; + } + + function appHeader() { + const header = element("header", "app-header"); + const identity = element("div", "app-identity"); + const brandMark = element("span", "brand-mark", "F"); + brandMark.setAttribute("aria-hidden", "true"); + identity.append(brandMark); + const copy = element("div", "identity-copy"); + copy.append(element("span", "product-label", "Flyover"), element("h1", "", manifest.application.title)); + identity.append(copy); + + const actions = element("div", "header-actions"); + const profile = element("label", "header-profile"); + profile.append( + element("span", "visually-hidden", "Capture profile"), + profileSelect("", "header-profile"), + ); + actions.append(profile); + + const search = iconButton("search", "Search", "header-button search-trigger", true); + search.id = "search-trigger"; + search.setAttribute("aria-keyshortcuts", "/ Meta+K Control+K"); + search.append(element("kbd", "", "⌘K")); + search.addEventListener("click", () => openPanel("search", search.id)); + actions.append(search); + + const build = iconButton("info", "Build details", "header-button build-trigger"); + build.id = "build-trigger"; + build.append(element("code", "commit-label", manifest.build.commit.slice(0, 8))); + if (manifest.build.dirty) { + build.setAttribute("aria-label", "Build details, uncommitted changes"); + build.title = "Build details, uncommitted changes"; + build.append(element("span", "dirty-dot")); + } + build.addEventListener("click", () => openPanel("build", build.id)); + actions.append(build); + header.append(identity, actions); + return header; + } + + function bottomDock() { + const dock = element("nav", "bottom-dock"); + dock.setAttribute("aria-label", "Atlas controls"); + + const viewTabs = element("div", "segmented-control view-tabs"); + viewTabs.setAttribute("aria-label", "Atlas view"); + for (const item of [["canvas", "canvas", "Canvas"], ["list", "list", "List"]]) { + const button = iconButton(item[1], item[2], "segment", true); + button.setAttribute("aria-pressed", String(state.view === item[0])); + button.dataset.focusKey = "view-" + item[0]; + button.addEventListener("click", () => chooseView(item[0])); + viewTabs.append(button); + } + dock.append(viewTabs, element("span", "dock-separator")); + + if (state.view === "canvas") { + const zoom = element("div", "zoom-controls"); + const minus = iconButton("minus", "Zoom out"); + minus.addEventListener("click", () => setZoom(state.zoom - 0.1)); + const value = element("output", "zoom-value", Math.round(state.zoom * 100) + "%"); + value.id = "zoom-value"; + const plus = iconButton("plus", "Zoom in"); + plus.addEventListener("click", () => setZoom(state.zoom + 0.1)); + zoom.append(minus, value, plus); + + const fitGroup = iconButton("fit", "Fit current group", "dock-action", true); + fitGroup.setAttribute("aria-keyshortcuts", "0"); + fitGroup.addEventListener("click", () => fitCurrentGroup()); + const fitAllButton = iconButton("fit", "Fit all", "dock-action fit-all", true); + fitAllButton.setAttribute("aria-keyshortcuts", "F"); + fitAllButton.addEventListener("click", () => fitAll()); + dock.append(zoom, fitGroup, fitAllButton, element("span", "dock-separator")); + } + + const groups = iconButton("map", "Browse groups", "dock-action group-trigger", true); + groups.id = "group-trigger"; + const currentGroup = groupByID.get(state.group); + groups.querySelector(".button-label").textContent = currentGroup?.title || "Groups"; + groups.addEventListener("click", () => openPanel("groups", groups.id)); + dock.append(groups); + + const filters = iconButton("filter", "Filters", "dock-action filter-trigger", true); + filters.id = "filter-trigger"; + filters.dataset.focusKey = "filters-control"; + filters.append(element("span", "filter-badge")); + filters.addEventListener("click", () => openPanel("filters", filters.id)); + dock.append(filters); + + const count = element("span", "result-count"); + count.id = "result-count"; + count.setAttribute("aria-live", "polite"); + dock.append(count); + return dock; + } + + function variantSelector(screen, updateHistory = false, className = "") { + const select = element("select", className); + select.setAttribute("aria-label", "State for " + screen.title); + select.dataset.focusKey = (updateHistory ? "inspector-state-" : "atlas-state-") + screen.id; + for (const variant of screen.variants) { + const option = element("option", "", variant.title); + option.value = variant.id; + option.selected = variant.id === selectedVariants.get(screen.id); + select.append(option); + } + select.addEventListener("change", event => { + event.stopPropagation(); + chooseVariant(screen, select.value, updateHistory); + }); + return select; + } + + function routeButton(route, screen, direction) { + const destinationID = direction === "outgoing" ? route.destinationScreenID : route.sourceScreenID; + const destination = screenByID.get(destinationID); + const cue = direction === "outgoing" + ? (route.kind === "modal" ? "Modal" : "Push") + : (route.kind === "modal" ? "Presented from" : "Back to"); + const title = direction === "outgoing" && route.label + ? route.label : (destination?.title || destinationID); + const button = element("button", "route-chip " + route.kind); + button.type = "button"; + button.append(icon("route"), element("span", "", cue + " · " + title)); + button.addEventListener("click", event => { + event.stopPropagation(); + openScreen(destinationID); + }); + return button; + } + + function routeButtons(screen, direction) { + const links = element("div", "route-links"); + const ids = direction === "incoming" ? screen.incomingRouteIDs : screen.outgoingRouteIDs; + for (const id of ids) { + const route = routeByID.get(id); + if (route) links.append(routeButton(route, screen, direction)); + } + if (ids.length === 0) links.append(element("span", "no-routes", "None")); + return links; + } + + function screenImage(screen, className = "", eager = false, fullResolution = false) { + const image = element("img", className); + image.draggable = false; + image.loading = eager ? "eager" : "lazy"; + image.decoding = "async"; + const source = fullResolution ? imagePath(screen) : thumbnailPath(screen); + if (eager) image.src = source; + else image.dataset.src = source; + image.dataset.captureExtent = screenVariant(screen)?.captureExtent || "viewport"; + image.alt = screen.title + " — " + (screenVariant(screen)?.title || "Default"); + return image; + } + + function captureViewportSize(screen) { + if (screen.viewport.kind === "fixed" && screen.viewport.fixedSize) { + return screen.viewport.fixedSize; + } + const profile = profileByID.get(state.profile); + if (profile?.device === "tablet") return { width: 834, height: 1194 }; + if (profile?.orientation === "landscape") return { width: 874, height: 402 }; + return { width: 402, height: 874 }; + } + + function captureViewportAspect(screen) { + const size = captureViewportSize(screen); + return size.width / size.height; + } + + function captureLabel(extent) { + if (extent === "fullContent") return "Full content"; + if (extent === "fullContent2D") return "Full content 2D"; + if (!extent) return "Viewport"; + return extent[0].toUpperCase() + extent.slice(1); + } + + function canvasView() { + const main = element("main", "canvas-view"); + const context = element("button", "canvas-context"); + context.type = "button"; + context.setAttribute("aria-label", "Browse canvas groups"); + const group = groupByID.get(state.group) || manifest.groups[0]; + context.append( + element("span", "context-kicker", "Group " + ((group?.order || 0) + 1) + " of " + manifest.groups.length), + element("strong", "", group?.title || "Atlas"), + ); + context.addEventListener("click", () => openPanel("groups", "group-trigger")); + main.append(context); + + const legend = element("div", "route-legend"); + legend.append(legendItem("push", "Push"), legendItem("modal", "Modal")); + main.append(legend); + + const viewport = element("section", "canvas-viewport"); + viewport.id = "canvas-viewport"; + viewport.setAttribute("aria-label", "Screen atlas canvas"); + const scaled = element("div", "canvas-scaled"); + scaled.id = "canvas-scaled"; + const stage = element("div", "canvas-stage"); + stage.id = "canvas-stage"; + stage.style.width = manifest.canvas.size.width + "px"; + stage.style.height = manifest.canvas.size.height + "px"; + + for (const item of manifest.canvas.groupFrames) { + const catalogGroup = groupByID.get(item.id); + const shelf = element("section", "group-shelf"); + shelf.dataset.groupId = item.id; + Object.assign(shelf.style, rectStyle(item.frame)); + const shelfHeader = element("header", "shelf-header"); + shelfHeader.append( + element("span", "shelf-index", String((catalogGroup?.order || 0) + 1).padStart(2, "0")), + element("h2", "", catalogGroup?.title || item.id), + ); + shelf.append(shelfHeader); + stage.append(shelf); + } + for (const item of manifest.canvas.depthBandFrames) { + const band = element("div", "depth-band"); + band.dataset.groupId = item.groupID; + band.dataset.entry = String(item.kind !== "unlinked" && item.depth === 0); + Object.assign(band.style, rectStyle(item.frame)); + const title = item.kind === "unlinked" ? "Unlinked" : (item.depth === 0 ? "Entry" : "Depth " + item.depth); + band.append(element("span", "", title)); + stage.append(band); + } + stage.append(routeCanvas()); + for (const screen of manifest.screens) stage.append(screenCard(screen)); + scaled.append(stage); + viewport.append(scaled); + viewport.addEventListener("scroll", scheduleCanvasNavigationUpdate, { passive: true }); + main.append(viewport, emptyResults()); + + requestAnimationFrame(() => { + applyZoom(); + if (state.pendingCanvasAction === "fit-initial") { + state.pendingCanvasAction = null; + fitFirstGroup(); + } else if (state.pendingCanvasAction === "fit-group") { + state.pendingCanvasAction = null; + fitCurrentGroup("auto"); + } else if (state.pendingCanvasAction === "fit-all") { + state.pendingCanvasAction = null; + fitAll("auto"); + } else if (state.pendingCanvasPosition) { + const position = state.pendingCanvasPosition; + state.pendingCanvasPosition = null; + viewport.scrollTo(position); + state.canvas.scrollLeft = position.left; + state.canvas.scrollTop = position.top; + updateCanvasNavigation(); + } else if (!state.canvas.initialized) { + fitFirstGroup(); + } else { + viewport.scrollTo({ left: state.canvas.scrollLeft, top: state.canvas.scrollTop }); + updateCanvasNavigation(); + } + state.canvas.initialized = true; + updateCanvasImageResidency(); + }); + installCanvasPinch(viewport); + return main; + } + + function legendItem(kind, title) { + const item = element("span", ""); + item.append(element("i", kind), document.createTextNode(title)); + return item; + } + + function screenCard(screen) { + const variant = screenVariant(screen); + const card = element("article", "screen-card"); + card.dataset.screenId = screen.id; + card.dataset.groupId = screen.groupID; + card.dataset.hidden = String(!matchesFilters(screen)); + Object.assign(card.style, rectStyle(screen.frame)); + + const header = element("header", "card-header"); + const heading = element("div", "card-heading"); + heading.append(element("h3", "", screen.title)); + const inspect = iconButton("fit", "Inspect " + screen.title, "card-inspect"); + inspect.addEventListener("click", () => openScreen(screen.id, true)); + header.append(heading, inspect); + + const imageButton = element("button", "card-image-button"); + imageButton.type = "button"; + imageButton.setAttribute("aria-label", "Inspect " + screen.title); + imageButton.dataset.focusKey = "atlas-screen-" + screen.id; + const device = element("span", "device-preview"); + device.dataset.captureExtent = variant?.captureExtent || "viewport"; + device.style.setProperty("--viewport-aspect", captureViewportAspect(screen)); + device.append(screenImage(screen)); + imageButton.append(device); + imageButton.addEventListener("click", () => openScreen(screen.id, true)); + + const imageMeta = element("div", "card-image-meta"); + const routeCount = screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length; + imageMeta.append( + element("span", "capture-badge", captureLabel(variant?.captureExtent)), + element("span", "route-count", routeCount + (routeCount === 1 ? " route" : " routes")), + ); + imageButton.append(imageMeta); + + const footer = element("footer", "card-footer"); + if (screen.variants.length > 1) { + footer.append(element("span", "footer-label", "State"), variantSelector(screen, false, "card-state-select")); + } else { + footer.append(element("span", "state-dot"), element("span", "single-state", variant?.title || "Default")); + } + card.append(header, imageButton, footer); + + card.addEventListener("pointerenter", () => setRouteFocus(screen.id)); + card.addEventListener("pointerleave", () => { + if (!card.matches(":focus-within")) setRouteFocus(null); + }); + card.addEventListener("focusin", () => setRouteFocus(screen.id)); + card.addEventListener("focusout", event => { + if (!card.contains(event.relatedTarget)) setRouteFocus(null); + }); + return card; + } + + function routeCanvas() { + const namespace = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(namespace, "svg"); + svg.classList.add("canvas-routes"); + svg.setAttribute("width", manifest.canvas.size.width); + svg.setAttribute("height", manifest.canvas.size.height); + svg.setAttribute("viewBox", "0 0 " + manifest.canvas.size.width + " " + manifest.canvas.size.height); + for (const route of manifest.routes) { + const group = document.createElementNS(namespace, "g"); + group.setAttribute("class", "route route-" + route.kind); + group.dataset.routeId = route.id; + group.dataset.sourceScreenId = route.sourceScreenID; + group.dataset.destinationScreenId = route.destinationScreenID; + const geometry = route.geometry; + const path = document.createElementNS(namespace, "path"); + path.setAttribute("d", "M " + geometry.start.x + " " + geometry.start.y + + " C " + geometry.firstControl.x + " " + geometry.firstControl.y + + ", " + geometry.secondControl.x + " " + geometry.secondControl.y + + ", " + geometry.end.x + " " + geometry.end.y); + const arrow = document.createElementNS(namespace, "polygon"); + arrow.setAttribute("points", geometry.end.x + "," + geometry.end.y + + " " + geometry.firstArrowPoint.x + "," + geometry.firstArrowPoint.y + + " " + geometry.secondArrowPoint.x + "," + geometry.secondArrowPoint.y); + group.append(path, arrow); + svg.append(group); + } + return svg; + } + + function rectStyle(rect) { + return { left: rect.x + "px", top: rect.y + "px", width: rect.width + "px", height: rect.height + "px" }; + } + + function listView() { + const main = element("main", "list-view"); + main.addEventListener("scroll", scheduleListImageResidencyUpdate, { passive: true }); + const intro = element("header", "list-intro"); + const copy = element("div", ""); + copy.append(element("p", "eyebrow", "Captured catalog")); + copy.append(element("h2", "", quantity(manifest.screens.length, "screen") + ", ready to review")); + intro.append(copy, element("p", "list-summary", quantity(manifest.groups.length, "group") + " · " + + quantity(manifest.routes.length, "route") + " · " + quantity(manifest.profiles.length, "profile"))); + main.append(intro); + + for (const group of manifest.groups) { + const section = element("section", "list-group"); + section.dataset.listGroupId = group.id; + const heading = element("header", "list-group-header"); + heading.append( + element("span", "group-number", String(group.order + 1).padStart(2, "0")), + element("h2", "", group.title), + element("span", "group-count", quantity(group.screenIDs.length, "screen")), + ); + section.append(heading); + const rows = element("div", "list-rows"); + for (const screenID of group.screenIDs) { + const screen = screenByID.get(screenID); + if (screen) rows.append(listRow(screen)); + } + section.append(rows); + main.append(section); + } + main.append(emptyResults()); + requestAnimationFrame(() => { + main.scrollTop = state.list.scrollTop; + updateListImageResidency(); + }); + return main; + } + + function listRow(screen) { + const variant = screenVariant(screen); + const row = element("article", "list-row"); + row.dataset.screenId = screen.id; + row.dataset.groupId = screen.groupID; + row.dataset.hidden = String(!matchesFilters(screen)); + + const thumbnail = element("button", "list-thumbnail"); + thumbnail.type = "button"; + thumbnail.setAttribute("aria-label", "Inspect " + screen.title + ", " + + screen.incomingRouteIDs.length + " incoming and " + screen.outgoingRouteIDs.length + " outgoing routes"); + thumbnail.dataset.focusKey = "atlas-screen-" + screen.id; + thumbnail.append(screenImage(screen)); + thumbnail.addEventListener("click", () => openScreen(screen.id, true)); + + const identity = element("div", "list-identity"); + identity.append(element("h3", "", screen.title)); + if (screen.variants.length > 1) { + const stateField = element("label", "list-state"); + stateField.append(element("span", "", "State"), variantSelector(screen)); + identity.append(stateField); + } else { + identity.append(element("p", "secondary", variant?.title || "Default")); + } + identity.append(element("p", "list-route-summary secondary", screen.incomingRouteIDs.length + " in · " + + screen.outgoingRouteIDs.length + " out")); + + const facts = element("div", "list-facts"); + facts.append( + element("span", "capture-badge", captureLabel(variant?.captureExtent)), + element("span", "secondary", screen.incomingRouteIDs.length + " in · " + + screen.outgoingRouteIDs.length + " out"), + ); + + const routes = element("div", "list-routes"); + for (const routeID of screen.outgoingRouteIDs) { + const route = routeByID.get(routeID); + if (route) routes.append(routeButton(route, screen, "outgoing")); + } + for (const routeID of screen.incomingRouteIDs) { + const route = routeByID.get(routeID); + if (route) routes.append(routeButton(route, screen, "incoming")); + } + + const inspect = iconButton("right", "Inspect " + screen.title, "list-inspect"); + inspect.addEventListener("click", () => openScreen(screen.id, true)); + row.append(thumbnail, identity, facts, routes, inspect); + return row; + } + + function inspector() { + const screen = screenByID.get(state.screen); + if (!screen) return null; + const variant = screenVariant(screen); + const metadata = imageMetadata(screen); + const group = groupByID.get(screen.groupID); + const screenIndex = manifest.screens.findIndex(item => item.id === screen.id); + const dialog = element("dialog", "inspector"); + dialog.id = "inspector"; + dialog.dataset.details = String(state.inspectorDetails); + dialog.setAttribute("aria-labelledby", "inspector-title"); + + const header = element("header", "inspector-header"); + const heading = element("div", "inspector-heading"); + heading.append( + element("p", "eyebrow", (group?.title || "Ungrouped") + " · " + + (screenIndex + 1) + " of " + manifest.screens.length), + element("h2", "selectable-text", screen.title), + ); + heading.querySelector("h2").id = "inspector-title"; + const actions = element("div", "inspector-header-actions"); + const raw = element("a", "inspector-action", "PNG"); + raw.href = imagePath(screen); + raw.target = "_blank"; + raw.rel = "noopener"; + raw.setAttribute("aria-label", "Open raw PNG"); + raw.append(icon("external")); + const details = iconButton("info", "Capture details", "inspector-action", true); + details.id = "details-trigger"; + details.setAttribute("aria-controls", "inspector-details"); + details.setAttribute("aria-expanded", String(state.inspectorDetails)); + details.addEventListener("click", toggleInspectorDetails); + const close = iconButton("close", "Close inspector", "inspector-action close-inspector"); + close.autofocus = true; + close.addEventListener("click", closeInspector); + actions.append(raw, details, close); + header.append(heading, actions); + + const canvas = element("section", "inspection-canvas"); + const extentClass = variant.captureExtent === "fullContent2D" + ? " full-content full-content-2d" + : (variant.captureExtent === "fullContent" ? " full-content" : ""); + const imageFrame = element("div", "inspector-image " + state.inspectorScale + extentClass); + imageFrame.id = "inspector-image"; + const device = element("div", "inspector-device"); + if (extentClass) { + device.tabIndex = 0; + device.dataset.captureScroller = ""; + device.setAttribute("role", "region"); + device.setAttribute("aria-label", screen.title + " scrollable full-content capture"); + } + const fullImage = screenImage(screen, "", true, true); + if (metadata) { + const viewportSize = captureViewportSize(screen); + device.style.setProperty("--point-width", metadata.pointWidth + "px"); + device.style.setProperty("--point-height", metadata.pointHeight + "px"); + device.style.setProperty("--viewport-width", viewportSize.width + "px"); + device.style.setProperty("--viewport-height", viewportSize.height + "px"); + device.style.setProperty("--aspect-ratio", metadata.pointWidth / metadata.pointHeight); + } + device.append(fullImage); + imageFrame.append(device); + const caption = element("div", "image-caption"); + caption.append(element("span", "capture-badge", captureLabel(variant.captureExtent)), element("span", "", imageDimensions(metadata))); + canvas.append(imageFrame, caption); + + const drawer = element("aside", "inspector-details"); + drawer.id = "inspector-details"; + drawer.setAttribute("aria-hidden", String(!state.inspectorDetails)); + drawer.inert = !state.inspectorDetails; + const drawerHeader = element("header", "details-header"); + drawerHeader.append( + element("p", "eyebrow", "Screen details"), + element("h3", "selectable-text", screen.title), + ); + const drawerClose = iconButton("close", "Close details", "icon-button details-close"); + drawerClose.addEventListener("click", toggleInspectorDetails); + drawerHeader.append(drawerClose); + drawer.append(drawerHeader, inspectorMetadata(screen, variant, metadata)); + drawer.append(routeSection("Outgoing", screen, "outgoing")); + drawer.append(routeSection("Incoming", screen, "incoming")); + + dialog.append(header, canvas, drawer, inspectorDock(screen)); + dialog.addEventListener("cancel", event => { + event.preventDefault(); + if (state.inspectorDetails) toggleInspectorDetails(); + else closeInspector(); + }); + return dialog; + } + + function inspectorDock(screen) { + const dock = element("nav", "inspector-dock"); + dock.setAttribute("aria-label", "Inspector controls"); + const previous = iconButton("left", "Previous screen", "icon-button inspector-previous"); + previous.setAttribute("aria-keyshortcuts", "ArrowLeft ["); + previous.dataset.focusKey = "inspector-previous"; + previous.addEventListener("click", () => openScreen(neighboringScreen(-1)?.id, true)); + dock.append(previous, element("span", "dock-separator")); + + const stateField = element("label", "inspector-field"); + stateField.append(element("span", "field-label", "State"), variantSelector(screen, true)); + const profileField = element("label", "inspector-field profile-field"); + profileField.append( + element("span", "field-label", "Profile"), + profileSelect("", "inspector-profile"), + ); + dock.append(stateField, profileField, element("span", "dock-separator")); + + const scale = element("div", "segmented-control scale-tabs"); + scale.setAttribute("aria-label", "Image scale"); + for (const option of [["fit", "Fit"], ["actual", "100%"]]) { + const button = element("button", "segment", option[1]); + button.type = "button"; + button.setAttribute("aria-pressed", String(state.inspectorScale === option[0])); + button.addEventListener("click", () => { + state.inspectorScale = option[0]; + updateInspectorScale(); + }); + scale.append(button); + } + dock.append(scale, element("span", "dock-separator")); + + const next = iconButton("right", "Next screen", "icon-button inspector-next"); + next.setAttribute("aria-keyshortcuts", "ArrowRight ]"); + next.dataset.focusKey = "inspector-next"; + next.addEventListener("click", () => openScreen(neighboringScreen(1)?.id, true)); + dock.append(next); + return dock; + } + + function imageDimensions(metadata) { + if (!metadata) return "Image metadata unavailable"; + return Math.round(metadata.pointWidth) + " × " + Math.round(metadata.pointHeight) + " pt · " + + metadata.pixelWidth + " × " + metadata.pixelHeight + " px @" + metadata.scale + "×"; + } + + function inspectorMetadata(screen, variant, metadata) { + const section = element("section", "detail-section"); + section.append(element("p", "eyebrow", "Capture")); + const list = element("dl", "detail-list"); + list.append( + metadataRow("State", variant.title), + metadataRow("Extent", captureLabel(variant.captureExtent)), + metadataRow("Profile", profileByID.get(state.profile)?.title || state.profile), + metadataRow("Viewport", screen.viewport.kind === "fixed" && screen.viewport.fixedSize + ? Math.round(screen.viewport.fixedSize.width) + " × " + Math.round(screen.viewport.fixedSize.height) + : "Profile device"), + metadataRow("Scale", metadata ? metadata.scale + "×" : "Unknown"), + ); + section.append(list); + return section; + } + + function routeSection(title, screen, direction) { + const section = element("section", "detail-section"); + const ids = direction === "incoming" ? screen.incomingRouteIDs : screen.outgoingRouteIDs; + section.append(element("p", "eyebrow", title + " · " + ids.length)); + section.append(routeButtons(screen, direction)); + return section; + } + + function toggleInspectorDetails() { + state.inspectorDetails = !state.inspectorDetails; + const dialog = document.getElementById("inspector"); + const drawer = document.getElementById("inspector-details"); + const trigger = document.getElementById("details-trigger"); + if (dialog) dialog.dataset.details = String(state.inspectorDetails); + if (drawer) { + drawer.setAttribute("aria-hidden", String(!state.inspectorDetails)); + drawer.inert = !state.inspectorDetails; + } + if (trigger) trigger.setAttribute("aria-expanded", String(state.inspectorDetails)); + if (state.inspectorDetails) drawer?.querySelector(".details-close")?.focus(); + else trigger?.focus(); + updateInspectorDetailsAccessibility(); + fitInspectorImage(); + } + + function updateInspectorDetailsAccessibility() { + const detailsCoverContent = Boolean( + state.inspectorDetails + && window.matchMedia?.("(max-width: 760px), (max-height: 520px)")?.matches, + ); + for (const selector of [".inspection-canvas", ".inspector-dock"]) { + const content = document.querySelector(selector); + if (!content) continue; + content.inert = detailsCoverContent; + content.setAttribute("aria-hidden", String(detailsCoverContent)); + } + } + + function updateInspectorScale() { + const image = document.getElementById("inspector-image"); + if (image) image.className = image.className.replace(/ fit| actual/g, "") + " " + state.inspectorScale; + fitInspectorImage(); + for (const button of document.querySelectorAll(".scale-tabs button")) { + button.setAttribute("aria-pressed", String( + (button.textContent === "Fit" && state.inspectorScale === "fit") + || (button.textContent === "100%" && state.inspectorScale === "actual"), + )); + } + } + + function fitInspectorImage() { + const frame = document.getElementById("inspector-image"); + const device = frame?.querySelector(".inspector-device"); + const screen = screenByID.get(state.screen); + const variant = screen ? screenVariant(screen) : null; + const metadata = screen ? imageMetadata(screen) : null; + if (!frame || !device || !metadata || !variant) return; + if (state.inspectorScale !== "fit" + || variant.captureExtent === "fullContent" + || variant.captureExtent === "fullContent2D") { + device.style.removeProperty("width"); + device.style.removeProperty("height"); + return; + } + const aspect = metadata.pointWidth / metadata.pointHeight; + const width = Math.min(metadata.pointWidth, frame.clientWidth, frame.clientHeight * aspect); + device.style.width = Math.max(width, 1) + "px"; + device.style.height = Math.max(width / aspect, 1) + "px"; + } + + function observeInspectorSize() { + inspectorResizeObserver?.disconnect(); + inspectorResizeObserver = null; + const frame = document.getElementById("inspector-image"); + if (!frame || typeof ResizeObserver === "undefined") return; + inspectorResizeObserver = new ResizeObserver(fitInspectorImage); + inspectorResizeObserver.observe(frame); + } + + function closeInspector() { + state.pendingFocusKey = state.inspectorReturnFocusKey; + state.screen = null; + state.routeFocus = null; + state.inspectorDetails = false; + renderOrNavigate(); + } + + function openPanel(panel, triggerID) { + state.panel = panel; + state.panelTrigger = triggerID; + if (panel !== "search") state.commandQuery = ""; + render(); + } + + function closePanel() { + const triggerID = state.panelTrigger; + state.panel = null; + state.commandQuery = ""; + render(); + requestAnimationFrame(() => document.getElementById(triggerID)?.focus()); + } + + function activePanel() { + if (!state.panel || state.screen) return null; + if (state.panel === "search") return searchPanel(); + if (state.panel === "groups") return groupsPanel(); + if (state.panel === "filters") return filtersPanel(); + if (state.panel === "build") return buildPanel(); + return null; + } + + function panelShell(className, label) { + const dialog = element("dialog", "panel " + className); + dialog.setAttribute("aria-label", label); + dialog.addEventListener("cancel", event => { + event.preventDefault(); + closePanel(); + }); + dialog.addEventListener("click", event => { + if (event.target === dialog) closePanel(); + }); + return dialog; + } + + function panelHeader(kicker, title) { + const header = element("header", "panel-header"); + const copy = element("div", ""); + copy.append(element("p", "eyebrow", kicker), element("h2", "", title)); + const close = iconButton("close", "Close", "icon-button panel-close"); + close.addEventListener("click", closePanel); + header.append(copy, close); + return header; + } + + function searchPanel() { + const dialog = panelShell("command-palette", "Search the atlas"); + const surface = element("div", "panel-surface command-surface"); + const field = element("label", "command-field"); + field.append(icon("search")); + const input = element("input", ""); + input.id = "command-search"; + input.type = "search"; + input.autofocus = true; + input.placeholder = "Find a group, screen, state, or route"; + input.autocomplete = "off"; + input.value = state.commandQuery; + input.setAttribute("aria-label", "Search groups, screens, states, and routes"); + const close = element("button", "escape-key", "Esc"); + close.type = "button"; + close.setAttribute("aria-label", "Close search"); + close.addEventListener("click", closePanel); + field.append(input, close); + const results = element("div", "command-results"); + results.id = "command-results"; + renderCommandResults(results, input.value); + input.addEventListener("input", () => { + state.commandQuery = input.value; + renderCommandResults(results, input.value); + }); + input.addEventListener("keydown", event => moveCommandFocus(event, results)); + surface.append(field, results); + dialog.append(surface); + return dialog; + } + + function commandEntries(query) { + const terms = query.trim().toLocaleLowerCase().split(/ +/).filter(Boolean); + if (terms.length === 0) { + const currentScreens = manifest.screens + .filter(screen => screen.groupID === state.group) + .slice(0, 6) + .map(screen => screenCommand(screen)); + return [...manifest.groups.map(groupCommand), ...currentScreens]; + } + + const entries = []; + for (const group of manifest.groups) entries.push(groupCommand(group)); + for (const screen of manifest.screens) { + entries.push(screenCommand(screen)); + for (const variant of screen.variants) entries.push(variantCommand(screen, variant)); + } + for (const route of manifest.routes) entries.push(routeCommand(route)); + return entries + .filter(entry => terms.every(term => entry.search.includes(term))) + .sort((first, second) => commandScore(first, terms) - commandScore(second, terms) + || first.title.localeCompare(second.title)) + .slice(0, 14); + } + + function commandScore(entry, terms) { + const title = entry.title.toLocaleLowerCase(); + if (terms.every(term => title === term)) return 0; + if (terms.every(term => title.startsWith(term))) return 1; + if (terms.every(term => title.includes(term))) return 2; + return entry.kind === "Screen" ? 3 : 4; + } + + function groupCommand(group) { + return { + kind: "Group", + title: group.title, + subtitle: quantity(group.screenIDs.length, "screen"), + search: (group.title + " group").toLocaleLowerCase(), + action: () => { + state.group = group.id; + state.view = "canvas"; + state.panel = null; + state.pendingCanvasAction = "fit-group"; + renderOrNavigate(); + }, + }; + } + + function screenCommand(screen) { + const group = groupByID.get(screen.groupID); + return { + kind: "Screen", + title: screen.title, + subtitle: (group?.title || "Ungrouped") + " · " + screen.variants.length + + (screen.variants.length === 1 ? " state" : " states"), + search: searchableText(screen), + action: () => openScreen(screen.id, true), + }; + } + + function variantCommand(screen, variant) { + const group = groupByID.get(screen.groupID); + return { + kind: "State", + title: screen.title + " — " + variant.title, + subtitle: group?.title || "Ungrouped", + search: (screen.title + " " + variant.title + " " + (group?.title || "")).toLocaleLowerCase(), + action: () => { + selectedVariants.set(screen.id, variant.id); + openScreen(screen.id, true); + }, + }; + } + + function routeCommand(route) { + const source = screenByID.get(route.sourceScreenID); + const destination = screenByID.get(route.destinationScreenID); + const title = route.label || (source?.title || route.sourceScreenID) + " → " + + (destination?.title || route.destinationScreenID); + return { + kind: route.kind === "modal" ? "Modal route" : "Push route", + title, + subtitle: (source?.title || route.sourceScreenID) + " → " + + (destination?.title || route.destinationScreenID), + search: (title + " " + route.kind + " " + (source?.title || "") + " " + + (destination?.title || "")).toLocaleLowerCase(), + action: () => openScreen(route.destinationScreenID), + }; + } + + function renderCommandResults(container, query) { + container.replaceChildren(); + const entries = commandEntries(query); + container.append(element("p", "command-section-label", query.trim() ? "Best matches" : "Jump to")); + if (entries.length === 0) { + const empty = element("div", "command-empty"); + empty.append(element("strong", "", "No matches"), element("span", "", "Try another screen or state name.")); + container.append(empty); + return; + } + entries.forEach((entry, index) => { + const button = element("button", "command-result"); + button.type = "button"; + button.dataset.commandIndex = String(index); + const glyph = element("span", "command-glyph"); + glyph.append(icon(entry.kind === "Group" ? "map" : entry.kind.includes("route") ? "route" : "canvas")); + const copy = element("span", "command-copy"); + copy.append(element("strong", "", entry.title), element("small", "", entry.subtitle)); + button.append(glyph, copy, element("span", "command-kind", entry.kind), icon("right")); + button.addEventListener("click", entry.action); + button.addEventListener("keydown", event => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const buttons = [...container.querySelectorAll(".command-result")]; + const offset = event.key === "ArrowDown" ? 1 : -1; + const next = (index + offset + buttons.length) % buttons.length; + buttons[next].focus(); + }); + container.append(button); + }); + } + + function moveCommandFocus(event, results) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return; + const buttons = [...results.querySelectorAll(".command-result")]; + if (buttons.length === 0) return; + event.preventDefault(); + if (event.key === "Enter") { + buttons[0].click(); + return; + } + (event.key === "ArrowDown" ? buttons[0] : buttons[buttons.length - 1]).focus(); + } + + function groupsPanel() { + const dialog = panelShell("sheet-panel groups-panel", "Browse groups"); + const surface = element("div", "panel-surface sheet-surface"); + surface.append(panelHeader("Navigate", "Groups")); + surface.append(miniMap()); + const navigation = element("nav", "group-navigation"); + navigation.setAttribute("aria-label", "Canvas groups"); + for (const group of manifest.groups) { + const button = element("button", "group-button"); + button.type = "button"; + button.dataset.groupId = group.id; + button.setAttribute("aria-current", String(group.id === state.group)); + const copy = element("span", ""); + copy.append( + element("strong", "", group.title), + element("small", "", quantity(group.screenIDs.length, "screen")), + ); + button.append(element("span", "group-index", String(group.order + 1).padStart(2, "0")), copy, icon("right")); + button.addEventListener("click", () => { + state.group = group.id; + state.view = "canvas"; + state.panel = null; + state.pendingCanvasAction = "fit-group"; + renderOrNavigate(); + }); + navigation.append(button); + } + surface.append(navigation); + dialog.append(surface); + return dialog; + } + + function miniMap() { + const namespace = "http://www.w3.org/2000/svg"; + const wrapper = element("div", "mini-map"); + const svg = document.createElementNS(namespace, "svg"); + svg.id = "mini-map-svg"; + svg.setAttribute("viewBox", "0 0 " + manifest.canvas.size.width + " " + manifest.canvas.size.height); + svg.setAttribute("role", "group"); + svg.setAttribute("tabindex", "0"); + svg.setAttribute("aria-label", "Canvas overview. Select a position or use arrow keys to pan the atlas."); + for (const group of manifest.canvas.groupFrames) { + const rect = document.createElementNS(namespace, "rect"); + Object.entries(rectAttributes(group.frame)).forEach(([key, value]) => rect.setAttribute(key, value)); + rect.setAttribute("class", "mini-group"); + rect.dataset.groupId = group.id; + svg.append(rect); + } + for (const screen of manifest.screens) { + const rect = document.createElementNS(namespace, "rect"); + Object.entries(rectAttributes(screen.frame)).forEach(([key, value]) => rect.setAttribute(key, value)); + rect.setAttribute("class", "mini-screen"); + rect.dataset.screenId = screen.id; + svg.append(rect); + } + const visible = document.createElementNS(namespace, "rect"); + visible.id = "mini-map-viewport"; + visible.setAttribute("class", "mini-viewport"); + svg.append(visible); + svg.addEventListener("click", event => moveFromMiniMap(svg, event.clientX, event.clientY)); + svg.addEventListener("keydown", event => { + const offsets = { + ArrowLeft: [-0.4, 0], + ArrowRight: [0.4, 0], + ArrowUp: [0, -0.4], + ArrowDown: [0, 0.4], + }; + const offset = offsets[event.key]; + if (!offset) return; + event.preventDefault(); + const viewport = document.getElementById("canvas-viewport"); + const { width, height } = canvasViewportSize(viewport); + if (viewport) { + viewport.scrollBy({ left: width * offset[0], top: height * offset[1] }); + scheduleCanvasNavigationUpdate(); + return; + } + queueCanvasPosition( + state.canvas.scrollLeft + width * offset[0], + state.canvas.scrollTop + height * offset[1], + ); + }); + wrapper.append(svg); + return wrapper; + } + + function moveFromMiniMap(svg, clientX, clientY) { + const matrix = svg.getScreenCTM(); + if (!matrix) return; + const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()); + const viewport = document.getElementById("canvas-viewport"); + const { width, height } = canvasViewportSize(viewport); + queueCanvasPosition(point.x * state.zoom - width / 2, point.y * state.zoom - height / 2); + } + + function queueCanvasPosition(left, top) { + const viewport = document.getElementById("canvas-viewport"); + const { width: viewportWidth, height: viewportHeight } = canvasViewportSize(viewport); + const maximumLeft = Math.max(manifest.canvas.size.width * state.zoom - viewportWidth, 0); + const maximumTop = Math.max(manifest.canvas.size.height * state.zoom - viewportHeight, 0); + state.pendingCanvasPosition = { + left: Math.min(Math.max(left, 0), maximumLeft), + top: Math.min(Math.max(top, 0), maximumTop), + }; + state.canvas.initialized = true; + state.view = "canvas"; + state.panel = null; + renderOrNavigate(); + } + + function rectAttributes(rect) { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + } + + function filtersPanel() { + const dialog = panelShell("sheet-panel filters-panel", "Filter screens"); + const surface = element("div", "panel-surface sheet-surface"); + surface.append(panelHeader("Refine", "Filters")); + const fields = element("div", "filter-fields"); + const groupOptions = [["all", "All groups"], ...manifest.groups.map(group => [group.id, group.title])]; + fields.append( + filterSelect("Group", state.filters.group, groupOptions, value => { + state.filters.group = value; + updateSearchVisibility(); + }), + filterSelect("Capture", state.filters.extent, [ + ["all", "All captures"], ["viewport", "Viewport"], ["intrinsic", "Intrinsic"], + ["fullContent", "Full content"], ["fullContent2D", "Full content 2D"], + ], value => { + state.filters.extent = value; + updateSearchVisibility(); + }), + filterSelect("Routes", state.filters.routes, [ + ["all", "Any route state"], ["linked", "Linked"], ["unlinked", "Unlinked"], + ["incoming", "Has incoming"], ["outgoing", "Has outgoing"], + ], value => { + state.filters.routes = value; + updateSearchVisibility(); + }), + ); + surface.append(fields); + const footer = element("footer", "panel-footer"); + const clear = element("button", "secondary-button", "Reset filters"); + clear.type = "button"; + clear.addEventListener("click", () => { + state.filters = { group: "all", extent: "all", routes: "all" }; + showFilteredResults(); + }); + const done = element("button", "primary-button", "Show results"); + done.type = "button"; + done.addEventListener("click", showFilteredResults); + footer.append(clear, done); + surface.append(footer); + dialog.append(surface); + return dialog; + } + + function showFilteredResults() { + const matchingScreens = manifest.screens.filter(matchesFilters); + if (state.view === "canvas" && matchingScreens.length > 0) { + const currentGroupHasMatch = matchingScreens.some(screen => screen.groupID === state.group); + state.group = currentGroupHasMatch ? state.group : matchingScreens[0].groupID; + state.pendingCanvasAction = "fit-group"; + } else if (state.view === "list") { + const list = document.querySelector(".list-view"); + if (list) list.scrollTop = 0; + state.list.scrollTop = 0; + } + closePanel(); + } + + function filterSelect(labelText, value, options, onChange) { + const label = element("label", "filter-field"); + label.append(element("span", "field-label", labelText)); + const select = element("select", ""); + for (const pair of options) { + const option = element("option", "", pair[1]); + option.value = pair[0]; + option.selected = pair[0] === value; + select.append(option); + } + select.addEventListener("change", () => onChange(select.value)); + label.append(select); + return label; + } + + function buildPanel() { + const dialog = panelShell("sheet-panel build-panel", "Build details"); + const surface = element("div", "panel-surface sheet-surface"); + surface.append(panelHeader("Artifact", "Build details")); + const status = element("div", "build-status"); + status.append(element("span", manifest.build.dirty ? "status-dot dirty" : "status-dot")); + const copy = element("div", ""); + copy.append( + element("strong", "", manifest.build.dirty ? "Uncommitted changes" : "Clean build"), + element("span", "", formatGeneratedAt(manifest.build.generatedAt)), + ); + status.append(copy); + const list = element("dl", "detail-list build-list"); + list.append( + metadataRow("Commit", manifest.build.commit), metadataRow("Branch", manifest.build.branch || "Detached"), + metadataRow("Xcode", manifest.build.xcodeVersion), metadataRow("Simulator", manifest.build.simulatorDevice), + metadataRow("Simulator OS", manifest.build.simulatorOS), + ); + surface.append(status, list); + dialog.append(surface); + return dialog; + } + + function emptyResults() { + const empty = element("section", "empty-results"); + empty.id = "empty-results"; + empty.hidden = true; + empty.append(icon("filter"), element("h2", "", "No screens match")); + empty.append(element("p", "", "Change the filters to show more of the atlas.")); + const clear = element("button", "primary-button", "Reset filters"); + clear.type = "button"; + clear.addEventListener("click", () => { + state.filters = { group: "all", extent: "all", routes: "all" }; + render(); + requestAnimationFrame(() => document.getElementById("filter-trigger")?.focus()); + }); + empty.append(clear); + return empty; + } + + function updateSearchVisibility() { + const visible = visibleScreenIDs(); + for (const node of document.querySelectorAll("[data-screen-id]")) { + if (node.matches(".route")) continue; + node.dataset.hidden = String(!visible.has(node.dataset.screenId)); + } + for (const group of manifest.groups) { + const hasResults = group.screenIDs.some(id => visible.has(id)); + for (const node of document.querySelectorAll('[data-group-id="' + CSS.escape(group.id) + '"]')) { + node.dataset.empty = String(!hasResults); + } + const listGroup = document.querySelector('[data-list-group-id="' + CSS.escape(group.id) + '"]'); + if (listGroup) listGroup.hidden = !hasResults; + } + for (const route of document.querySelectorAll(".route")) { + const sourceVisible = visible.has(route.dataset.sourceScreenId); + const destinationVisible = visible.has(route.dataset.destinationScreenId); + route.dataset.hidden = String(!sourceVisible || !destinationVisible); + } + const count = document.getElementById("result-count"); + if (count) count.textContent = visible.size + " / " + manifest.screens.length; + const empty = document.getElementById("empty-results"); + if (empty) empty.hidden = visible.size !== 0; + updateFilterStatus(); + updateRouteFocus(); + if (state.view === "canvas") updateCanvasImageResidency(); + else updateListImageResidency(); + } + + function updateFilterStatus() { + const activeCount = Object.values(state.filters).filter(value => value !== "all").length; + const button = document.getElementById("filter-trigger"); + if (!button) return; + button.dataset.active = String(activeCount > 0); + button.setAttribute("aria-label", activeCount > 0 ? "Filters, " + activeCount + " active" : "Filters"); + const badge = button.querySelector(".filter-badge"); + if (badge) badge.textContent = activeCount > 0 ? String(activeCount) : ""; + } + + function setRouteFocus(screenID) { + state.routeFocus = screenID; + updateRouteFocus(); + } + + function updateRouteFocus() { + const focused = state.routeFocus; + const connected = new Map(); + if (focused) { + const screen = screenByID.get(focused); + for (const id of screen?.incomingRouteIDs || []) { + const route = routeByID.get(id); + if (route) connected.set(route.sourceScreenID, "upstream"); + } + for (const id of screen?.outgoingRouteIDs || []) { + const route = routeByID.get(id); + if (route) connected.set(route.destinationScreenID, "downstream"); + } + } + for (const card of document.querySelectorAll(".screen-card")) { + card.dataset.routeRelation = card.dataset.screenId === focused + ? "focus" : (connected.get(card.dataset.screenId) || (focused ? "unrelated" : "none")); + } + for (const route of document.querySelectorAll(".route")) { + const isConnected = route.dataset.sourceScreenId === focused + || route.dataset.destinationScreenId === focused; + route.dataset.routeRelation = isConnected ? "focus" : (focused ? "unrelated" : "none"); + } + } + + function residentScreenIDs(candidates) { + const result = new Set(); + let pixels = 0; + for (const candidate of candidates) { + if (!candidate.isVisible) continue; + result.add(candidate.screen.id); + pixels += candidate.imagePixels; + } + for (const candidate of candidates) { + if (candidate.isVisible) continue; + if (result.size >= targetResidentImageCount) break; + if (result.size > 0 && pixels + candidate.imagePixels > targetResidentPixelCount) continue; + result.add(candidate.screen.id); + pixels += candidate.imagePixels; + } + return result; + } + + function screenImagePixels(screen) { + const metadata = imageMetadata(screen); + if (!metadata) return 0; + const usesThumbnail = Boolean(metadata.thumbnailRelativePath); + const width = usesThumbnail ? (metadata.thumbnailPixelWidth || metadata.pixelWidth) : metadata.pixelWidth; + const height = usesThumbnail ? (metadata.thumbnailPixelHeight || metadata.pixelHeight) : metadata.pixelHeight; + return width * height; + } + + function updateCanvasImageResidency() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport || state.view !== "canvas") return; + if (state.screen) { + updateResidentImages(".screen-card", new Set()); + return; + } + const visibleRect = { + left: viewport.scrollLeft / state.zoom, + top: viewport.scrollTop / state.zoom, + right: (viewport.scrollLeft + viewport.clientWidth) / state.zoom, + bottom: (viewport.scrollTop + viewport.clientHeight) / state.zoom, + }; + const center = { + x: (visibleRect.left + visibleRect.right) / 2, + y: (visibleRect.top + visibleRect.bottom) / 2, + }; + const candidates = manifest.screens + .filter(screen => matchesFilters(screen) && rectIntersects(screen.frame, visibleRect)) + .map(screen => ({ + screen, + imagePixels: screenImagePixels(screen), + isVisible: true, + distance: squaredDistance(screen.frame.x + screen.frame.width / 2, + screen.frame.y + screen.frame.height / 2, center.x, center.y), + })) + .sort((lhs, rhs) => lhs.distance - rhs.distance + || lhs.screen.frame.y - rhs.screen.frame.y + || lhs.screen.frame.x - rhs.screen.frame.x); + updateResidentImages(".screen-card", residentScreenIDs(candidates)); + } + + let listResidencyFrame = null; + function scheduleListImageResidencyUpdate() { + if (listResidencyFrame !== null) return; + listResidencyFrame = requestAnimationFrame(() => { + listResidencyFrame = null; + updateListImageResidency(); + }); + } + + function updateListImageResidency() { + const list = document.querySelector(".list-view"); + if (!list || state.view !== "list") return; + if (state.screen) { + updateResidentImages(".list-row", new Set()); + return; + } + const viewport = list.getBoundingClientRect(); + const centerY = (viewport.top + viewport.bottom) / 2; + const candidates = []; + for (const row of document.querySelectorAll(".list-row")) { + const screen = screenByID.get(row.dataset.screenId); + if (!screen || !matchesFilters(screen)) continue; + const frame = row.getBoundingClientRect(); + if (frame.bottom < viewport.top - 120 || frame.top > viewport.bottom + 120) continue; + candidates.push({ + screen, + imagePixels: screenImagePixels(screen), + isVisible: frame.bottom > viewport.top && frame.top < viewport.bottom, + distance: Math.abs((frame.top + frame.bottom) / 2 - centerY), + }); + } + candidates.sort((lhs, rhs) => lhs.distance - rhs.distance || lhs.screen.screenOrder - rhs.screen.screenOrder); + updateResidentImages(".list-row", residentScreenIDs(candidates)); + } + + function updateResidentImages(containerSelector, residentIDs) { + for (const container of document.querySelectorAll(containerSelector)) { + const image = container.querySelector("img[data-src]"); + if (!image) continue; + if (residentIDs.has(container.dataset.screenId)) { + image.loading = "eager"; + if (image.getAttribute("src") !== image.dataset.src) image.src = image.dataset.src; + } else { + image.removeAttribute("src"); + image.loading = "lazy"; + } + } + } + + function rectIntersects(frame, visibleRect) { + return frame.x < visibleRect.right && frame.x + frame.width > visibleRect.left + && frame.y < visibleRect.bottom && frame.y + frame.height > visibleRect.top; + } + + function squaredDistance(x1, y1, x2, y2) { + const x = x1 - x2; + const y = y1 - y2; + return x * x + y * y; + } + + function installCanvasPinch(viewport) { + let gesture = null; + viewport.addEventListener("touchstart", event => { + if (event.touches.length !== 2) return; + const midpoint = touchMidpoint(event.touches); + const bounds = viewport.getBoundingClientRect(); + gesture = { + distance: touchDistance(event.touches), + zoom: state.zoom, + minimumZoom: Math.min(minimumManualZoom, state.zoom), + canvasX: (viewport.scrollLeft + midpoint.x - bounds.left) / state.zoom, + canvasY: (viewport.scrollTop + midpoint.y - bounds.top) / state.zoom, + }; + }, { passive: true }); + viewport.addEventListener("touchmove", event => { + if (!gesture || event.touches.length !== 2) return; + event.preventDefault(); + const midpoint = touchMidpoint(event.touches); + const bounds = viewport.getBoundingClientRect(); + const nextZoom = Math.max(gesture.minimumZoom, Math.min(maximumZoom, + gesture.zoom * touchDistance(event.touches) / Math.max(gesture.distance, 1))); + state.zoom = nextZoom; + applyZoom(); + viewport.scrollTo({ + left: gesture.canvasX * nextZoom - (midpoint.x - bounds.left), + top: gesture.canvasY * nextZoom - (midpoint.y - bounds.top), + }); + captureCanvasPosition(); + scheduleCanvasNavigationUpdate(); + }, { passive: false }); + const endGesture = event => { + if (event.touches.length < 2) gesture = null; + }; + viewport.addEventListener("touchend", endGesture, { passive: true }); + viewport.addEventListener("touchcancel", endGesture, { passive: true }); + } + + function touchMidpoint(touches) { + return { + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }; + } + + function touchDistance(touches) { + return Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY, + ); + } + + function captureCanvasPosition() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + state.canvas.scrollLeft = viewport.scrollLeft; + state.canvas.scrollTop = viewport.scrollTop; + state.canvas.viewportWidth = viewport.clientWidth; + state.canvas.viewportHeight = viewport.clientHeight; + } + + function captureListPosition() { + const list = document.querySelector(".list-view"); + if (list) state.list.scrollTop = list.scrollTop; + } + + function captureViewPosition() { + captureCanvasPosition(); + captureListPosition(); + } + + function applyZoom() { + const stage = document.getElementById("canvas-stage"); + const scaled = document.getElementById("canvas-scaled"); + if (!stage || !scaled) return; + stage.style.transform = "scale(" + state.zoom + ")"; + scaled.style.width = manifest.canvas.size.width * state.zoom + "px"; + scaled.style.height = manifest.canvas.size.height * state.zoom + "px"; + const value = document.getElementById("zoom-value"); + if (value) value.textContent = Math.round(state.zoom * 100) + "%"; + updateMiniMapViewport(); + } + + function setZoom(next) { + const viewport = document.getElementById("canvas-viewport"); + const oldZoom = state.zoom; + const center = viewport ? { + x: (viewport.scrollLeft + viewport.clientWidth / 2) / oldZoom, + y: (viewport.scrollTop + viewport.clientHeight / 2) / oldZoom, + } : null; + const minimumZoom = Math.min(minimumManualZoom, oldZoom); + state.zoom = Math.max(minimumZoom, Math.min(maximumZoom, next)); + applyZoom(); + if (viewport && center) { + viewport.scrollTo({ + left: center.x * state.zoom - viewport.clientWidth / 2, + top: center.y * state.zoom - viewport.clientHeight / 2, + }); + captureCanvasPosition(); + } + updateCanvasImageResidency(); + } + + function fitFrame(frame, behavior = "smooth") { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const padding = Math.min(Math.max(viewport.clientWidth * 0.055, 32), 80); + const horizontal = Math.max(viewport.clientWidth - padding * 2, 1) / Math.max(frame.width, 1); + const vertical = Math.max(viewport.clientHeight - padding * 2, 1) / Math.max(frame.height, 1); + state.zoom = Math.min(maximumZoom, horizontal, vertical); + applyZoom(); + const left = (frame.x + frame.width / 2) * state.zoom - viewport.clientWidth / 2; + const top = (frame.y + frame.height / 2) * state.zoom - viewport.clientHeight / 2; + const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; + viewport.scrollTo({ left, top, behavior: prefersReducedMotion ? "auto" : behavior }); + state.canvas.scrollLeft = Math.max(0, left); + state.canvas.scrollTop = Math.max(0, top); + updateCanvasImageResidency(); + } + + function fitAll(behavior = "smooth") { + fitFrame({ x: 0, y: 0, width: manifest.canvas.size.width, height: manifest.canvas.size.height }, behavior); + } + + function fitCurrentGroup(behavior = "smooth") { + const group = manifest.canvas.groupFrames.find(item => item.id === state.group) + || manifest.canvas.groupFrames[0]; + if (group) fitFrame(group.frame, behavior); + } + + function fitFirstGroup() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const initialWidth = manifest.canvas.initialFitSize?.width + || manifest.canvas.groupFrames[0]?.frame.width; + if (!initialWidth) return; + const framingInset = 16; + const horizontal = Math.max(viewport.clientWidth - framingInset * 2, 1) / initialWidth; + state.zoom = Math.min(1, horizontal); + applyZoom(); + viewport.scrollTo({ left: 0, top: 0, behavior: "auto" }); + state.canvas.scrollLeft = 0; + state.canvas.scrollTop = 0; + updateCanvasNavigation(); + } + + let canvasNavigationFrame = null; + function scheduleCanvasNavigationUpdate() { + if (canvasNavigationFrame !== null) return; + canvasNavigationFrame = requestAnimationFrame(() => { + canvasNavigationFrame = null; + captureCanvasPosition(); + updateCanvasNavigation(); + updateCanvasImageResidency(); + }); + } + + function updateCanvasNavigation() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const centerX = (viewport.scrollLeft + viewport.clientWidth / 2) / state.zoom; + const centerY = (viewport.scrollTop + viewport.clientHeight / 2) / state.zoom; + let nearest = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const group of manifest.canvas.groupFrames) { + const frame = group.frame; + const dx = Math.max(frame.x - centerX, 0, centerX - frame.x - frame.width); + const dy = Math.max(frame.y - centerY, 0, centerY - frame.y - frame.height); + const distance = dx * dx + dy * dy; + if (distance < nearestDistance) { + nearest = group.id; + nearestDistance = distance; + } + } + if (nearest && nearest !== state.group) { + state.group = nearest; + updateActiveGroup(); + } + updateMiniMapViewport(); + } + + function updateActiveGroup() { + for (const button of document.querySelectorAll(".group-button")) { + button.setAttribute("aria-current", String(button.dataset.groupId === state.group)); + } + for (const group of document.querySelectorAll(".mini-group")) { + group.dataset.active = String(group.dataset.groupId === state.group); + } + const current = groupByID.get(state.group); + const context = document.querySelector(".canvas-context"); + if (context && current) { + context.querySelector(".context-kicker").textContent = "Group " + (current.order + 1) + + " of " + manifest.groups.length; + context.querySelector("strong").textContent = current.title; + } + const dockLabel = document.querySelector(".group-trigger .button-label"); + if (dockLabel && current) dockLabel.textContent = current.title; + } + + function updateMiniMapViewport() { + const viewport = document.getElementById("canvas-viewport"); + const rect = document.getElementById("mini-map-viewport"); + if (!rect) return; + const { width, height } = canvasViewportSize(viewport); + const scrollLeft = viewport?.scrollLeft ?? state.canvas.scrollLeft; + const scrollTop = viewport?.scrollTop ?? state.canvas.scrollTop; + rect.setAttribute("x", scrollLeft / state.zoom); + rect.setAttribute("y", scrollTop / state.zoom); + rect.setAttribute("width", width / state.zoom); + rect.setAttribute("height", height / state.zoom); + } + + function canvasViewportSize(viewport) { + return { + width: viewport?.clientWidth || state.canvas.viewportWidth || window.innerWidth, + height: viewport?.clientHeight || state.canvas.viewportHeight || window.innerHeight, + }; + } + + function visibleFocusTarget(focusKey) { + if (!focusKey) return null; + const target = document.querySelector('[data-focus-key="' + CSS.escape(focusKey) + '"]'); + if (!target || target.closest('[data-hidden="true"], [aria-hidden="true"]')) return null; + const style = window.getComputedStyle(target); + return style.display === "none" || style.visibility === "hidden" ? null : target; + } + + function render() { + if (!state.pendingCanvasPosition) captureViewPosition(); + if (canvasNavigationFrame !== null) { + cancelAnimationFrame(canvasNavigationFrame); + canvasNavigationFrame = null; + } + if (listResidencyFrame !== null) { + cancelAnimationFrame(listResidencyFrame); + listResidencyFrame = null; + } + inspectorResizeObserver?.disconnect(); + inspectorResizeObserver = null; + const activeElement = document.activeElement; + const activeFocusKey = activeElement?.dataset.focusKey; + const canRestoreActiveFocus = !state.panel + && (!state.screen || activeElement?.closest(".inspector")); + const focusKey = state.pendingFocusKey || (canRestoreActiveFocus ? activeFocusKey : null); + state.pendingFocusKey = null; + const app = element("div", "application"); + app.append(appHeader()); + app.append(state.view === "canvas" ? canvasView() : listView()); + app.append(bottomDock()); + root.replaceChildren(app); + + const inspection = inspector(); + if (inspection) { + root.append(inspection); + requestAnimationFrame(() => { + inspection.showModal(); + updateInspectorDetailsAccessibility(); + fitInspectorImage(); + observeInspectorSize(); + }); + } else { + const panel = activePanel(); + if (panel) { + root.append(panel); + requestAnimationFrame(() => { + panel.showModal(); + updateMiniMapViewport(); + }); + } + } + requestAnimationFrame(() => { + updateSearchVisibility(); + if (focusKey) { + (visibleFocusTarget(focusKey) || visibleFocusTarget("filters-control"))?.focus(); + } + }); + } + + window.addEventListener("hashchange", () => { + if (!state.pendingCanvasPosition) captureViewPosition(); + parseHash(); + render(); + }); + window.addEventListener("resize", () => { + if (state.screen) requestAnimationFrame(() => { + updateInspectorDetailsAccessibility(); + fitInspectorImage(); + }); + if (state.view === "canvas") scheduleCanvasNavigationUpdate(); + else scheduleListImageResidencyUpdate(); + }); + window.addEventListener("keydown", event => { + const target = event.target; + const isEditing = target instanceof HTMLInputElement + || target instanceof HTMLSelectElement + || target instanceof HTMLTextAreaElement + || target?.isContentEditable; + const isCaptureScroller = target?.closest?.("[data-capture-scroller]"); + const commandSearch = (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k"; + if (commandSearch || (event.key === "/" && !isEditing && !event.metaKey && !event.ctrlKey && !event.altKey)) { + event.preventDefault(); + if (!state.screen) openPanel("search", "search-trigger"); + return; + } + if (isEditing || isCaptureScroller || event.metaKey || event.ctrlKey || event.altKey || state.panel) return; + if (state.screen && (event.key === "ArrowLeft" || event.key === "[")) { + event.preventDefault(); + openScreen(neighboringScreen(-1)?.id, true); + } else if (state.screen && (event.key === "ArrowRight" || event.key === "]")) { + event.preventDefault(); + openScreen(neighboringScreen(1)?.id, true); + } else if (state.screen && event.key.toLocaleLowerCase() === "i") { + event.preventDefault(); + toggleInspectorDetails(); + } else if (!state.screen && state.view === "canvas" && event.key.toLocaleLowerCase() === "f") { + event.preventDefault(); + fitAll(); + } else if (!state.screen && state.view === "canvas" && event.key === "0") { + event.preventDefault(); + fitCurrentGroup(); + } else if (!state.screen && state.view === "canvas" && (event.key === "+" || event.key === "=")) { + event.preventDefault(); + setZoom(state.zoom + 0.1); + } else if (!state.screen && state.view === "canvas" && event.key === "-") { + event.preventDefault(); + setZoom(state.zoom - 0.1); + } + }); + + parseHash(); + render(); +})(); diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css new file mode 100644 index 000000000..89bf5d7ba --- /dev/null +++ b/Shared/Flyover/Web/assets/styles.css @@ -0,0 +1,2312 @@ +:root { + color-scheme: light dark; + --accent: #3157e8; + --accent-strong: #2444c4; + --accent-soft: #e8edff; + --modal: #a12aad; + --background: #eef0f5; + --canvas: #e9ebf1; + --surface: rgba(255, 255, 255, 0.88); + --surface-solid: #ffffff; + --surface-muted: #f5f6f9; + --text: #151721; + --secondary: #5d6270; + --tertiary: #686d7b; + --line: rgba(33, 37, 48, 0.12); + --line-strong: rgba(33, 37, 48, 0.2); + --shadow-small: 0 1px 2px rgba(20, 24, 36, 0.08), 0 8px 24px rgba(20, 24, 36, 0.08); + --shadow-large: 0 24px 80px rgba(20, 24, 36, 0.2), 0 4px 18px rgba(20, 24, 36, 0.1); + --glass: blur(22px) saturate(150%); + --radius-small: 10px; + --radius-medium: 16px; + --radius-large: 24px; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; + font-synthesis: none; +} + +@media (prefers-color-scheme: dark) { + :root { + --accent: #7891ff; + --accent-strong: #9eafff; + --accent-soft: #242b4d; + --modal: #e28bee; + --background: #15161a; + --canvas: #111216; + --surface: rgba(38, 39, 45, 0.88); + --surface-solid: #24252b; + --surface-muted: #1c1d22; + --text: #f4f4f7; + --secondary: #a9acb6; + --tertiary: #9a9eaa; + --line: rgba(255, 255, 255, 0.11); + --line-strong: rgba(255, 255, 255, 0.2); + --shadow-small: 0 1px 2px rgba(0, 0, 0, 0.25), 0 10px 30px rgba(0, 0, 0, 0.22); + --shadow-large: 0 28px 90px rgba(0, 0, 0, 0.5), 0 4px 18px rgba(0, 0, 0, 0.3); + } +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; +} + +#app, +#app > dialog { + -webkit-user-select: none; + user-select: none; +} + +#app input, +#app textarea, +#app [contenteditable="true"], +#app .selectable-text { + -webkit-user-select: text; + user-select: text; +} + +#app img { + -webkit-user-drag: none; +} + +body { + overflow: hidden; + background: var(--background); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +button, +select, +input { + color: inherit; + font: inherit; +} + +button, +select, +a { + -webkit-tap-highlight-color: transparent; +} + +button { + border: 0; + cursor: pointer; +} + +button:focus-visible, +select:focus-visible, +input:focus-visible, +a:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 65%, transparent); + outline-offset: 3px; +} + +select { + min-height: 36px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0 30px 0 11px; + background: color-mix(in srgb, var(--surface-solid) 92%, transparent); +} + +h1, +h2, +h3, +p { + margin: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.icon { + width: 19px; + height: 19px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.eyebrow, +.product-label, +.field-label, +.context-kicker, +.command-section-label { + color: var(--secondary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.secondary { + color: var(--secondary); +} + +.application { + display: grid; + grid-template-rows: 64px minmax(0, 1fr); + width: 100%; + height: 100dvh; + min-height: 0; +} + +.app-header { + position: relative; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-width: 0; + padding: 0 22px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 90%, transparent); + -webkit-backdrop-filter: var(--glass); + backdrop-filter: var(--glass); +} + +.app-identity, +.header-actions, +.header-button, +.dock-action, +.icon-button, +.segment, +.zoom-controls, +.inspector-action { + display: flex; + align-items: center; +} + +.app-identity { + min-width: 0; + gap: 11px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 11px; + background: linear-gradient(145deg, #5270f4, #2b48ca); + box-shadow: 0 7px 18px rgba(49, 87, 232, 0.3), inset 0 1px rgba(255, 255, 255, 0.28); + color: white; + font-size: 18px; + font-weight: 750; +} + +.identity-copy { + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.identity-copy h1 { + overflow: hidden; + font-size: 17px; + font-weight: 680; + letter-spacing: -0.015em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.product-label { + color: var(--accent); +} + +.header-actions { + gap: 8px; +} + +.header-profile select { + max-width: 180px; + height: 38px; + border-color: transparent; + background-color: var(--surface-muted); + font-size: 13px; + font-weight: 600; +} + +.header-button, +.dock-action, +.icon-button, +.inspector-action { + justify-content: center; + min-width: 38px; + min-height: 38px; + gap: 7px; + border: 1px solid transparent; + border-radius: 11px; + background: transparent; + color: var(--secondary); + text-decoration: none; +} + +.header-button:hover, +.dock-action:hover, +.icon-button:hover, +.inspector-action:hover { + background: var(--surface-muted); + color: var(--text); +} + +.search-trigger { + min-width: 156px; + justify-content: flex-start; + padding: 0 8px 0 11px; + border-color: var(--line); + background: var(--surface-muted); +} + +.search-trigger .button-label { + flex: 1; + text-align: left; +} + +kbd, +.escape-key { + min-width: 29px; + border: 1px solid var(--line); + border-radius: 7px; + padding: 3px 6px; + background: var(--surface-solid); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.06); + color: var(--secondary); + font-family: inherit; + font-size: 10px; + line-height: 1.2; + text-align: center; +} + +.build-trigger { + position: relative; + padding: 0 9px; +} + +.commit-label { + color: var(--secondary); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 11px; +} + +.dirty-dot { + position: absolute; + top: 7px; + right: 5px; + width: 7px; + height: 7px; + border: 2px solid var(--surface-solid); + border-radius: 50%; + background: #f59e0b; +} + +.canvas-view { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--canvas); +} + +.canvas-viewport { + position: absolute; + inset: 0; + overflow: auto; + overscroll-behavior: contain; + background-color: var(--canvas); + background-image: radial-gradient(circle, color-mix(in srgb, var(--text) 12%, transparent) 1px, transparent 1px); + background-position: 0 0; + background-size: 24px 24px; + scrollbar-color: color-mix(in srgb, var(--secondary) 32%, transparent) transparent; +} + +.canvas-scaled { + position: relative; +} + +.canvas-stage { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; +} + +.canvas-context, +.route-legend { + position: absolute; + z-index: 12; + top: 18px; + border: 1px solid color-mix(in srgb, var(--line) 78%, transparent); + border-radius: 14px; + background: var(--surface); + box-shadow: var(--shadow-small); + -webkit-backdrop-filter: var(--glass); + backdrop-filter: var(--glass); +} + +.canvas-context { + left: 20px; + display: grid; + min-width: 164px; + padding: 9px 13px 10px; + color: var(--text); + text-align: left; +} + +.canvas-context strong { + max-width: 240px; + overflow: hidden; + margin-top: 2px; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.route-legend { + right: 20px; + display: flex; + gap: 13px; + padding: 9px 12px; + color: var(--secondary); + font-size: 11px; + font-weight: 650; +} + +.route-legend span { + display: flex; + align-items: center; + gap: 6px; +} + +.route-legend i { + width: 18px; + height: 3px; + border-radius: 99px; + background: var(--accent); +} + +.route-legend i.modal { + background: repeating-linear-gradient(90deg, var(--modal) 0 5px, transparent 5px 8px); +} + +.group-shelf, +.depth-band, +.screen-card, +.canvas-routes { + position: absolute; +} + +.group-shelf { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--line-strong) 86%, transparent); + border-radius: 30px; + background: color-mix(in srgb, var(--surface-solid) 42%, transparent); +} + +.group-shelf[data-empty="true"] { + opacity: 0.35; +} + +.shelf-header { + display: flex; + align-items: center; + gap: 12px; + height: 76px; + padding: 0 30px; + border-bottom: 1px solid var(--line); +} + +.shelf-header h2 { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.025em; +} + +.shelf-index, +.group-number { + color: var(--accent); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + font-weight: 700; +} + +.depth-band { + border-left: 1px solid var(--line); + pointer-events: none; +} + +.depth-band[data-entry="true"] { + border-left: 0; +} + +.depth-band span { + position: absolute; + top: 16px; + left: 18px; + color: var(--tertiary); + font-size: 12px; + font-weight: 650; +} + +.depth-band[data-empty="true"] { + opacity: 0.25; +} + +.screen-card { + z-index: 4; + display: grid; + grid-template-rows: 58px minmax(0, 1fr) 58px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--line-strong) 90%, transparent); + border-radius: 25px; + background: var(--surface-solid); + box-shadow: 0 4px 10px rgba(25, 30, 44, 0.08), 0 20px 45px rgba(25, 30, 44, 0.1); + transition: border-color 180ms ease, box-shadow 180ms ease, opacity 180ms ease, transform 180ms ease; +} + +.screen-card:hover, +.screen-card:focus-within, +.screen-card[data-route-relation="focus"] { + border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); + box-shadow: 0 8px 18px rgba(25, 30, 44, 0.1), 0 28px 60px rgba(25, 30, 44, 0.16); + transform: translateY(-3px); +} + +.screen-card[data-hidden="true"] { + visibility: hidden; + opacity: 0; +} + +.screen-card[data-route-relation="unrelated"] { + opacity: 0.34; +} + +.screen-card[data-route-relation="upstream"], +.screen-card[data-route-relation="downstream"] { + border-color: color-mix(in srgb, var(--accent) 36%, var(--line)); +} + +.card-header, +.card-footer { + display: flex; + align-items: center; + min-width: 0; + padding: 0 17px; + background: var(--surface-solid); +} + +.card-header { + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line); +} + +.card-heading { + min-width: 0; +} + +.card-heading h3 { + overflow: hidden; + font-size: 16px; + font-weight: 680; + letter-spacing: -0.015em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-inspect { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: transparent; + color: var(--accent); +} + +.card-inspect:hover { + background: var(--accent-soft); +} + +.card-image-button { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + place-items: center; + overflow: hidden; + padding: 0; + background: linear-gradient(145deg, color-mix(in srgb, var(--surface-muted) 96%, var(--accent-soft)), var(--surface-muted)); +} + +.device-preview { + display: grid; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + place-items: center; + overflow: hidden; +} + +.device-preview img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + transition: opacity 120ms ease; +} + +.device-preview[data-capture-extent="fullContent"], +.device-preview[data-capture-extent="fullContent2D"] { + width: auto; + max-width: 100%; + aspect-ratio: var(--viewport-aspect); + place-items: start center; +} + +.device-preview[data-capture-extent="fullContent"] img, +.device-preview[data-capture-extent="fullContent2D"] img { + width: 100%; + height: auto; + object-fit: fill; +} + +img[data-src]:not([src]) { + opacity: 0; +} + +.card-image-meta { + position: absolute; + right: 11px; + bottom: 11px; + left: 11px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + opacity: 0; + transform: translateY(5px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.screen-card:hover .card-image-meta, +.screen-card:focus-within .card-image-meta { + opacity: 1; + transform: translateY(0); +} + +.capture-badge, +.route-count { + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 99px; + padding: 5px 8px; + background: rgba(14, 16, 22, 0.74); + color: white; + font-size: 10px; + font-weight: 700; + line-height: 1; + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); +} + +.card-footer { + gap: 9px; + border-top: 1px solid var(--line); +} + +.footer-label { + color: var(--secondary); + font-size: 11px; + font-weight: 650; + text-transform: uppercase; +} + +.card-state-select { + flex: 1; + min-width: 0; + border: 0; + background-color: var(--surface-muted); + font-size: 13px; + font-weight: 600; +} + +.state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.single-state { + overflow: hidden; + color: var(--secondary); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.canvas-routes { + z-index: 3; + inset: 0; + overflow: visible; + pointer-events: none; +} + +.route path { + fill: none; + stroke: var(--accent); + stroke-linecap: round; + stroke-width: 3; + vector-effect: non-scaling-stroke; +} + +.route polygon { + fill: var(--accent); +} + +.route-modal path { + stroke: var(--modal); + stroke-dasharray: 9 8; +} + +.route-modal polygon { + fill: var(--modal); +} + +.route { + opacity: 0.55; + transition: opacity 160ms ease; +} + +.route[data-route-relation="focus"] { + opacity: 1; +} + +.route[data-route-relation="unrelated"] { + opacity: 0.07; +} + +.route[data-hidden="true"] { + display: none; +} + +.bottom-dock { + position: fixed; + z-index: 40; + bottom: max(18px, env(safe-area-inset-bottom)); + left: 50%; + display: flex; + align-items: center; + gap: 5px; + max-width: calc(100vw - 32px); + min-height: 58px; + padding: 7px; + border: 1px solid color-mix(in srgb, var(--line-strong) 82%, transparent); + border-radius: 18px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: var(--shadow-large); + transform: translateX(-50%); + -webkit-backdrop-filter: var(--glass); + backdrop-filter: var(--glass); +} + +.segmented-control, +.zoom-controls { + display: flex; + align-items: center; + padding: 3px; + border-radius: 12px; + background: var(--surface-muted); +} + +.segment { + justify-content: center; + min-height: 38px; + gap: 7px; + border-radius: 9px; + padding: 0 12px; + background: transparent; + color: var(--secondary); + font-size: 13px; + font-weight: 650; +} + +.segment[aria-pressed="true"] { + background: var(--surface-solid); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.06); + color: var(--text); +} + +.zoom-controls .icon-button { + min-width: 34px; + min-height: 34px; +} + +.zoom-value { + width: 46px; + color: var(--secondary); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 700; + text-align: center; +} + +.dock-action { + min-height: 42px; + padding: 0 10px; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.dock-action[data-active="true"] { + background: var(--accent-soft); + color: var(--accent); +} + +.dock-separator { + width: 1px; + height: 28px; + margin: 0 3px; + background: var(--line); +} + +.filter-trigger { + position: relative; +} + +.filter-badge:not(:empty) { + display: grid; + width: 17px; + height: 17px; + place-items: center; + border-radius: 99px; + background: var(--accent); + color: white; + font-size: 9px; + font-weight: 750; +} + +.result-count { + min-width: 48px; + padding: 0 6px; + color: var(--tertiary); + font-size: 10px; + font-variant-numeric: tabular-nums; + font-weight: 700; + text-align: center; +} + +.empty-results { + position: absolute; + z-index: 20; + top: 50%; + left: 50%; + display: grid; + width: min(360px, calc(100% - 40px)); + place-items: center; + padding: 34px; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: var(--surface-solid); + box-shadow: var(--shadow-large); + text-align: center; + transform: translate(-50%, -50%); +} + +.empty-results[hidden] { + display: none; +} + +.empty-results .icon { + width: 28px; + height: 28px; + margin-bottom: 16px; + color: var(--accent); +} + +.empty-results h2 { + font-size: 19px; +} + +.empty-results p { + margin: 7px 0 20px; + color: var(--secondary); + font-size: 13px; +} + +.primary-button, +.secondary-button { + min-height: 42px; + border-radius: 11px; + padding: 0 16px; + font-size: 13px; + font-weight: 700; +} + +.primary-button { + background: var(--accent); + color: white; +} + +.primary-button:hover { + background: var(--accent-strong); +} + +.secondary-button { + background: var(--surface-muted); + color: var(--text); +} + +.list-view { + min-height: 0; + overflow: auto; + padding: 54px max(28px, calc((100vw - 1180px) / 2)) 120px; + background: var(--background); +} + +.list-intro { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 48px; +} + +.list-intro h2 { + margin-top: 7px; + font-size: clamp(28px, 3vw, 42px); + font-weight: 720; + letter-spacing: -0.04em; +} + +.list-summary { + color: var(--secondary); + font-size: 13px; +} + +.list-group { + margin-bottom: 48px; +} + +.list-group-header { + position: sticky; + z-index: 5; + top: 0; + display: grid; + grid-template-columns: 40px 1fr auto; + align-items: center; + gap: 10px; + min-height: 60px; + border-bottom: 1px solid var(--line-strong); + background: color-mix(in srgb, var(--background) 90%, transparent); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); +} + +.list-group-header h2 { + font-size: 19px; + font-weight: 690; + letter-spacing: -0.02em; +} + +.group-count { + color: var(--secondary); + font-size: 12px; +} + +.list-rows { + display: grid; +} + +.list-row { + display: grid; + grid-template-columns: 112px minmax(190px, 1fr) 150px minmax(180px, 0.8fr) 44px; + align-items: center; + gap: 22px; + min-height: 146px; + border-bottom: 1px solid var(--line); + transition: opacity 160ms ease, background 160ms ease; +} + +.list-row:hover { + background: color-mix(in srgb, var(--surface-solid) 40%, transparent); +} + +.list-row[data-hidden="true"] { + display: none; +} + +.list-thumbnail { + display: grid; + width: 96px; + height: 118px; + place-items: center; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 13px; + padding: 0; + background: var(--surface-muted); + box-shadow: var(--shadow-small); +} + +.list-thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.list-thumbnail img[data-capture-extent="fullContent"], +.list-thumbnail img[data-capture-extent="fullContent2D"] { + object-fit: cover; + object-position: top center; +} + +.list-identity { + display: grid; + gap: 9px; + min-width: 0; +} + +.list-identity h3 { + overflow: hidden; + font-size: 17px; + font-weight: 680; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-state { + display: flex; + align-items: center; + gap: 8px; + color: var(--secondary); + font-size: 11px; + font-weight: 650; + text-transform: uppercase; +} + +.list-state select { + max-width: 210px; + min-width: 0; + text-transform: none; +} + +.list-facts { + display: grid; + justify-items: start; + gap: 9px; + font-size: 12px; +} + +.list-facts .capture-badge { + border-color: var(--line); + background: var(--surface-muted); + color: var(--secondary); +} + +.list-routes { + display: flex; + gap: 6px; + min-width: 0; + overflow-x: auto; + padding: 4px 0; + scrollbar-width: thin; +} + +.list-route-summary { + display: none; +} + +.route-chip { + display: flex; + align-items: center; + gap: 7px; + max-width: 100%; + min-height: 36px; + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line)); + border-radius: 10px; + padding: 7px 10px; + background: color-mix(in srgb, var(--accent-soft) 54%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 650; + text-align: left; + flex: 0 0 auto; +} + +.route-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.route-chip.modal { + border-color: color-mix(in srgb, var(--modal) 28%, var(--line)); + background: color-mix(in srgb, var(--modal) 9%, var(--surface-solid)); + color: var(--modal); +} + +.route-chip .icon { + width: 15px; + height: 15px; +} + +.list-inspect { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 50%; + background: var(--surface-muted); + color: var(--accent); +} + +.list-inspect:hover { + background: var(--accent-soft); +} + +dialog { + color: var(--text); +} + +dialog::backdrop { + background: rgba(8, 10, 16, 0.42); + -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); +} + +.panel { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + overflow: hidden; + border: 0; + padding: 0; + background: transparent; +} + +.panel-surface { + border: 1px solid var(--line-strong); + background: var(--surface-solid); + box-shadow: var(--shadow-large); +} + +.command-surface { + width: min(680px, calc(100vw - 32px)); + max-height: min(680px, calc(100dvh - 100px)); + margin: clamp(36px, 10vh, 100px) auto 0; + overflow: hidden; + border-radius: 22px; + animation: panel-in 180ms ease-out both; +} + +.command-field { + display: flex; + align-items: center; + gap: 12px; + min-height: 64px; + border-bottom: 1px solid var(--line); + padding: 0 15px 0 20px; +} + +.command-field > .icon { + width: 22px; + height: 22px; + color: var(--secondary); +} + +.command-field input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + font-size: 18px; + font-weight: 500; +} + +.command-results { + max-height: min(610px, calc(100dvh - 180px)); + overflow: auto; + padding: 12px; +} + +.command-section-label { + padding: 8px 10px 10px; +} + +.command-result { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto 18px; + align-items: center; + gap: 12px; + width: 100%; + min-height: 58px; + border-radius: 12px; + padding: 7px 11px; + background: transparent; + color: var(--text); + text-align: left; +} + +.command-result:hover, +.command-result:focus-visible { + background: var(--accent-soft); +} + +.command-glyph { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); + color: var(--accent); +} + +.command-copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.command-copy strong, +.command-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-copy strong { + font-size: 14px; + font-weight: 650; +} + +.command-copy small, +.command-kind { + color: var(--secondary); + font-size: 11px; +} + +.command-result > .icon { + color: var(--tertiary); +} + +.command-empty { + display: grid; + place-items: center; + padding: 58px 20px; + color: var(--secondary); + font-size: 13px; +} + +.command-empty strong { + margin-bottom: 5px; + color: var(--text); + font-size: 16px; +} + +.sheet-surface { + position: absolute; + top: 80px; + right: 20px; + bottom: 20px; + display: flex; + flex-direction: column; + width: min(410px, calc(100vw - 40px)); + overflow: auto; + border-radius: 22px; + animation: sheet-in 200ms ease-out both; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: 84px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.panel-header h2 { + margin-top: 3px; + font-size: 22px; + letter-spacing: -0.025em; +} + +.panel-close { + flex: 0 0 auto; + background: var(--surface-muted); +} + +.mini-map { + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.mini-map svg { + display: block; + width: 100%; + height: 170px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--canvas); + cursor: crosshair; +} + +.mini-group { + fill: color-mix(in srgb, var(--surface-solid) 76%, transparent); + stroke: var(--line-strong); + stroke-width: 10; +} + +.mini-group[data-active="true"] { + fill: color-mix(in srgb, var(--accent) 12%, var(--surface-solid)); + stroke: var(--accent); +} + +.mini-screen { + fill: color-mix(in srgb, var(--text) 28%, transparent); +} + +.mini-screen[data-hidden="true"] { + opacity: 0.08; +} + +.mini-viewport { + fill: color-mix(in srgb, var(--accent) 10%, transparent); + stroke: var(--accent); + stroke-width: 12; + vector-effect: non-scaling-stroke; +} + +.group-navigation { + display: grid; + gap: 5px; + padding: 12px; +} + +.group-button { + display: grid; + grid-template-columns: 38px 1fr 20px; + align-items: center; + gap: 10px; + min-height: 58px; + border-radius: 13px; + padding: 7px 10px; + background: transparent; + color: var(--text); + text-align: left; +} + +.group-button:hover, +.group-button[aria-current="true"] { + background: var(--accent-soft); +} + +.group-button > span:nth-child(2) { + display: grid; + gap: 3px; +} + +.group-button strong { + font-size: 14px; +} + +.group-button small { + color: var(--secondary); + font-size: 11px; +} + +.group-button > .icon { + color: var(--tertiary); +} + +.filter-fields { + display: grid; + gap: 20px; + padding: 24px 20px; +} + +.filter-field, +.inspector-field { + display: grid; + gap: 7px; +} + +.filter-field select { + width: 100%; + min-height: 44px; +} + +.panel-footer { + display: flex; + gap: 10px; + margin-top: auto; + padding: 18px 20px; + border-top: 1px solid var(--line); +} + +.panel-footer button { + flex: 1; +} + +.build-status { + display: flex; + align-items: center; + gap: 13px; + margin: 20px; + border-radius: 14px; + padding: 15px; + background: var(--surface-muted); +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #22a06b; + box-shadow: 0 0 0 5px color-mix(in srgb, #22a06b 14%, transparent); +} + +.status-dot.dirty { + background: #f59e0b; + box-shadow: 0 0 0 5px color-mix(in srgb, #f59e0b 14%, transparent); +} + +.build-status div { + display: grid; + gap: 4px; +} + +.build-status strong { + font-size: 14px; +} + +.build-status span:last-child { + color: var(--secondary); + font-size: 11px; +} + +.detail-list { + margin: 0; +} + +.build-list { + padding: 0 20px 24px; +} + +.metadata-row { + display: grid; + grid-template-columns: 94px minmax(0, 1fr); + gap: 16px; + padding: 11px 0; + border-bottom: 1px solid var(--line); + font-size: 12px; +} + +.metadata-row dt { + color: var(--secondary); +} + +.metadata-row dd { + overflow-wrap: anywhere; + margin: 0; + font-weight: 550; +} + +.inspector { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + overflow: hidden; + border: 0; + padding: 0; + background: #090a0e; + color: #f7f7f9; +} + +.inspector::backdrop { + background: #090a0e; +} + +.inspector-header { + position: absolute; + z-index: 10; + top: 16px; + right: 18px; + left: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 64px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 17px; + padding: 9px 10px 9px 17px; + background: rgba(31, 32, 38, 0.78); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.28); + -webkit-backdrop-filter: blur(22px) saturate(140%); + backdrop-filter: blur(22px) saturate(140%); + transition: right 220ms ease; +} + +.inspector[data-details="true"] .inspector-header { + right: 398px; +} + +.inspector-heading { + min-width: 0; +} + +.inspector-heading .eyebrow { + color: #979ba8; +} + +.inspector-heading h2 { + overflow: hidden; + margin-top: 3px; + font-size: 18px; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-header-actions { + display: flex; + align-items: center; + gap: 5px; +} + +.inspector-action { + min-height: 42px; + padding: 0 10px; + color: #c6c8d0; + font-size: 12px; + font-weight: 650; +} + +a.inspector-action { + flex-direction: row-reverse; +} + +.inspector-action:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.close-inspector { + width: 42px; + border-radius: 50%; + padding: 0; + background: rgba(255, 255, 255, 0.09); +} + +.inspection-canvas { + position: absolute; + inset: 0; + display: grid; + min-width: 0; + min-height: 0; + place-items: center; + overflow: hidden; + padding: 98px 44px 116px; + background: + radial-gradient(circle at 50% 42%, rgba(57, 62, 80, 0.3), transparent 46%), + #090a0e; + transition: margin-right 220ms ease; +} + +.inspector[data-details="true"] .inspection-canvas { + margin-right: 380px; +} + +.inspector-image { + display: grid; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + place-items: center; + overflow: auto; + scrollbar-color: rgba(255, 255, 255, 0.24) transparent; +} + +.inspector-device { + --point-width: 402px; + --point-height: 874px; + --viewport-width: 402px; + --viewport-height: 874px; + --aspect-ratio: 0.46; + display: grid; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 24px; + background: #15161b; + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.55), 0 4px 22px rgba(0, 0, 0, 0.45); +} + +.inspector-device img { + display: block; + max-width: none; +} + +.inspector-image.fit:not(.full-content) .inspector-device { + width: 100%; + height: 100%; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.inspector-image.fit:not(.full-content) img { + width: 100%; + height: 100%; + border-radius: 18px; + object-fit: contain; + filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.42)); +} + +.inspector-image.actual { + place-items: start center; +} + +.inspector-image.actual .inspector-device, +.inspector-image.actual img { + width: auto; + height: auto; +} + +.inspector-image.full-content.fit .inspector-device { + width: min(var(--point-width), 100%); + height: 100%; + overflow: auto; +} + +.inspector-image.full-content.fit img { + width: 100%; + max-width: none; + height: auto; + max-height: none; +} + +.inspector-image.full-content-2d.fit .inspector-device { + width: min(var(--viewport-width), 100%); + height: min(var(--viewport-height), 100%); + place-items: start; +} + +.inspector-image.full-content-2d.fit img { + width: var(--point-width); + height: var(--point-height); +} + +.image-caption { + position: absolute; + bottom: 88px; + left: 24px; + display: flex; + align-items: center; + gap: 9px; + color: #90949f; + font-size: 10px; +} + +.image-caption .capture-badge { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(31, 32, 38, 0.74); +} + +.inspector-dock { + position: absolute; + z-index: 12; + bottom: max(18px, env(safe-area-inset-bottom)); + left: 50%; + display: flex; + align-items: center; + gap: 6px; + min-height: 64px; + max-width: calc(100vw - 48px); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 19px; + padding: 7px; + background: rgba(31, 32, 38, 0.84); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.42); + transform: translateX(-50%); + -webkit-backdrop-filter: blur(22px) saturate(140%); + backdrop-filter: blur(22px) saturate(140%); + transition: margin-left 220ms ease; +} + +.inspector[data-details="true"] .inspector-dock { + margin-left: -190px; +} + +.inspector-dock .icon-button { + color: #d0d2d8; +} + +.inspector-dock .icon-button:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.inspector-dock .dock-separator { + background: rgba(255, 255, 255, 0.12); +} + +.inspector-field { + gap: 3px; + padding: 0 4px; +} + +.inspector-field .field-label { + color: #8e929d; + font-size: 9px; +} + +.inspector-field select { + max-width: 190px; + min-height: 30px; + border: 0; + padding-left: 0; + background-color: transparent; + color: #f7f7f9; + font-size: 12px; + font-weight: 650; +} + +.inspector-dock .segmented-control { + background: rgba(255, 255, 255, 0.08); +} + +.inspector-dock .segment { + min-height: 34px; + color: #aeb1bb; +} + +.inspector-dock .segment[aria-pressed="true"] { + background: rgba(255, 255, 255, 0.15); + box-shadow: none; + color: white; +} + +.inspector-details { + position: absolute; + z-index: 11; + top: 0; + right: 0; + bottom: 0; + width: 380px; + overflow: auto; + border-left: 1px solid rgba(255, 255, 255, 0.12); + padding: 0 20px 40px; + background: rgba(25, 26, 31, 0.96); + box-shadow: -24px 0 70px rgba(0, 0, 0, 0.3); + transform: translateX(100%); + transition: transform 220ms ease; + -webkit-backdrop-filter: blur(22px); + backdrop-filter: blur(22px); +} + +.inspector[data-details="true"] .inspector-details { + transform: translateX(0); +} + +.details-header { + position: sticky; + z-index: 2; + top: 0; + display: grid; + grid-template-columns: 1fr auto; + padding: 24px 0 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(25, 26, 31, 0.94); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); +} + +.details-header h3 { + margin-top: 4px; + font-size: 20px; +} + +.details-header .eyebrow, +.detail-section .eyebrow { + color: #8e929d; +} + +.details-close { + grid-row: 1 / 3; + grid-column: 2; + background: rgba(255, 255, 255, 0.08); + color: #d1d3da; +} + +.detail-section { + padding: 22px 0 4px; +} + +.detail-section .metadata-row { + border-color: rgba(255, 255, 255, 0.1); +} + +.detail-section .metadata-row dt, +.no-routes { + color: #8e929d; +} + +.route-links { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.inspector-details .route-chip { + width: 100%; + min-height: 42px; + border-color: rgba(120, 145, 255, 0.24); + background: rgba(120, 145, 255, 0.1); + color: #9aabff; +} + +.inspector-details .route-chip.modal { + border-color: rgba(211, 76, 230, 0.25); + background: rgba(211, 76, 230, 0.09); + color: #e28bee; +} + +.no-routes { + padding: 8px 0; + font-size: 12px; +} + +.error { + display: grid; + width: min(620px, calc(100% - 40px)); + min-height: 100%; + align-content: center; + margin: auto; +} + +.error h1 { + margin: 8px 0 10px; + font-size: 34px; +} + +.error p:last-child { + color: var(--secondary); +} + +@keyframes panel-in { + from { opacity: 0; transform: translateY(-10px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes sheet-in { + from { opacity: 0; transform: translateX(18px); } + to { opacity: 1; transform: translateX(0); } +} + +@media (max-width: 1050px) { + .list-row { + grid-template-columns: 104px minmax(170px, 1fr) 130px 44px; + } + + .list-routes { + display: none; + } + + .fit-all .button-label { + display: none; + } + + .inspector[data-details="true"] .inspection-canvas { + margin-right: 330px; + } + + .inspector[data-details="true"] .inspector-header { + right: 348px; + } + + .inspector-details { + width: 330px; + } + + .inspector[data-details="true"] .inspector-dock { + margin-left: -165px; + } +} + +@media (max-width: 760px) { + .application { + grid-template-rows: 56px minmax(0, 1fr); + } + + .app-header { + gap: 10px; + padding: 0 11px; + } + + .app-identity { + gap: 8px; + } + + .brand-mark { + width: 32px; + height: 32px; + border-radius: 10px; + } + + .product-label, + .commit-label, + .search-trigger .button-label, + .search-trigger kbd { + display: none; + } + + .identity-copy { + display: block; + } + + .identity-copy h1 { + max-width: 105px; + font-size: 15px; + } + + .header-actions { + gap: 2px; + } + + .header-profile select { + max-width: 122px; + height: 36px; + padding-right: 24px; + font-size: 11px; + } + + .search-trigger, + .build-trigger { + min-width: 38px; + width: 38px; + padding: 0; + justify-content: center; + } + + .canvas-context, + .route-legend { + top: 12px; + } + + .canvas-context { + left: 12px; + min-width: 140px; + } + + .route-legend { + right: 12px; + } + + .bottom-dock { + right: 10px; + bottom: max(10px, env(safe-area-inset-bottom)); + left: 10px; + max-width: none; + min-height: 56px; + justify-content: flex-start; + justify-content: safe center; + gap: 2px; + overflow-x: auto; + padding: 6px; + transform: none; + } + + .bottom-dock .dock-separator, + .bottom-dock .result-count, + .bottom-dock .dock-action .button-label { + display: none; + } + + .bottom-dock .dock-action { + min-width: 40px; + padding: 0 8px; + } + + .view-tabs .segment { + padding: 0 9px; + } + + .zoom-value { + width: 39px; + } + + .list-view { + padding: 34px 16px 104px; + } + + .list-intro { + display: grid; + gap: 12px; + margin-bottom: 32px; + } + + .list-intro h2 { + font-size: 30px; + } + + .list-group { + margin-bottom: 36px; + } + + .list-group-header { + grid-template-columns: 32px 1fr auto; + } + + .list-row { + grid-template-columns: 82px minmax(0, 1fr) 42px; + gap: 14px; + min-height: 122px; + } + + .list-thumbnail { + width: 76px; + height: 96px; + } + + .list-facts, + .list-routes { + display: none; + } + + .list-state { + display: grid; + gap: 4px; + } + + .list-state select { + max-width: 100%; + } + + .list-route-summary { + display: block; + font-size: 11px; + } + + .command-surface { + width: calc(100vw - 20px); + max-height: calc(100dvh - 32px); + margin-top: 10px; + border-radius: 18px; + } + + .command-field { + min-height: 58px; + padding-left: 15px; + } + + .command-field input { + font-size: 16px; + } + + .command-results { + max-height: calc(100dvh - 102px); + } + + .command-kind { + display: none; + } + + .command-result { + grid-template-columns: 38px minmax(0, 1fr) 18px; + } + + .sheet-surface { + top: auto; + right: 8px; + bottom: max(8px, env(safe-area-inset-bottom)); + left: 8px; + width: auto; + max-height: calc(100dvh - 24px); + border-radius: 22px; + animation-name: mobile-sheet-in; + } + + .mini-map svg { + height: 145px; + } + + .inspector-header { + top: 8px; + right: 8px; + left: 8px; + min-height: 58px; + border-radius: 15px; + padding: 7px 7px 7px 13px; + } + + .inspector[data-details="true"] .inspector-header { + right: 8px; + } + + .inspector-heading h2 { + max-width: 150px; + font-size: 15px; + } + + .inspector-heading .eyebrow { + font-size: 9px; + } + + .inspector-action { + min-width: 38px; + min-height: 38px; + padding: 0 7px; + } + + .inspector-action .button-label, + a.inspector-action { + font-size: 0; + } + + a.inspector-action .icon { + width: 19px; + height: 19px; + } + + .inspection-canvas, + .inspector[data-details="true"] .inspection-canvas { + margin-right: 0; + padding: 78px 12px 116px; + } + + .inspector-device { + border-radius: 18px; + } + + .image-caption { + bottom: 82px; + left: 12px; + } + + .image-caption span:last-child { + display: none; + } + + .inspector-dock, + .inspector[data-details="true"] .inspector-dock { + right: 8px; + bottom: max(8px, env(safe-area-inset-bottom)); + left: 8px; + min-height: 62px; + max-width: none; + gap: 2px; + margin-left: 0; + overflow-x: auto; + border-radius: 17px; + padding: 6px; + transform: none; + } + + .inspector-dock .dock-separator { + display: none; + } + + .inspector-field { + min-width: 112px; + } + + .inspector-field select { + max-width: 132px; + } + + .inspector-field.profile-field { + min-width: 108px; + } + + .inspector-details { + z-index: 20; + top: 72px; + width: 100%; + border-top: 1px solid rgba(255, 255, 255, 0.12); + border-left: 0; + border-radius: 24px 24px 0 0; + transform: translateY(100%); + } + + .inspector[data-details="true"] .inspector-details { + transform: translateY(0); + } +} + +@media (pointer: coarse), (max-width: 760px) { + .header-profile select, + .header-button, + .bottom-dock button, + .bottom-dock .segment, + .bottom-dock .zoom-controls .icon-button, + .list-state select, + .card-state-select, + .list-inspect, + .card-inspect, + .route-chip, + .escape-key, + .panel-close, + .details-close, + .primary-button, + .secondary-button, + .inspector-action, + .inspector-dock button, + .inspector-dock .segment, + .inspector-dock .inspector-field select { + min-width: 44px; + min-height: 44px; + } + + .header-profile select { + height: 44px; + } +} + +@media (max-height: 520px) { + .inspector-header { + top: 6px; + right: 8px; + left: 8px; + min-height: 50px; + border-radius: 14px; + padding: 5px 7px 5px 12px; + } + + .inspection-canvas, + .inspector[data-details="true"] .inspection-canvas { + margin-right: 0; + padding: 64px 12px 70px; + } + + .image-caption { + display: none; + } + + .inspector-dock, + .inspector[data-details="true"] .inspector-dock { + right: 8px; + bottom: max(6px, env(safe-area-inset-bottom)); + left: 8px; + min-height: 54px; + max-width: none; + margin-left: 0; + padding: 4px; + transform: none; + } + + .inspector-details { + z-index: 20; + top: 58px; + width: min(380px, 100%); + border-top: 1px solid rgba(255, 255, 255, 0.12); + border-left: 0; + border-radius: 20px 20px 0 0; + transform: translateY(100%); + } + + .inspector[data-details="true"] .inspector-details { + transform: translateY(0); + } +} + +@media (max-width: 440px) { + .route-legend { + display: none; + } + + .identity-copy h1 { + max-width: 72px; + } + + .header-profile select { + max-width: 104px; + } + + .view-tabs .button-label { + display: none; + } + + .view-tabs .segment { + width: 39px; + padding: 0; + } + + .inspector-heading h2 { + max-width: 105px; + } + + .inspector-dock .scale-tabs { + display: none; + } +} + +@keyframes mobile-sheet-in { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +@media (prefers-reduced-transparency: reduce) { + .app-header, + .bottom-dock, + .canvas-context, + .route-legend, + .inspector-header, + .inspector-dock, + .inspector-details { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } +} diff --git a/Shared/Flyover/Web/index.html b/Shared/Flyover/Web/index.html new file mode 100644 index 000000000..37c754467 --- /dev/null +++ b/Shared/Flyover/Web/index.html @@ -0,0 +1,16 @@ + + + + + + + Flyover QA Atlas + + + + +
+ + + + diff --git a/Shared/SnapshotKit/AGENTS.md b/Shared/SnapshotKit/AGENTS.md index ceda95f75..797d911b7 100644 --- a/Shared/SnapshotKit/AGENTS.md +++ b/Shared/SnapshotKit/AGENTS.md @@ -15,6 +15,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. - **Non-default values include `dark`, `xxxl`, `contrast`, `rtl`, `bold`, `accessibility`, and `iPad`.** The light/standard/default baseline stays empty. - **Reference-image filenames depend on this.** Treat omission rules as a wire format. - **When you add an axis, give it a default that is omitted.** That is how `layoutDirection`/`legibilityWeight` landed. +- **Treat explicit layout traits as non-default.** Their idiom and size classes must add one stable identifier token. - **Filter `.accessibility` configs out of previews.** `snapshotPreviews` drops them. - **Stuff keeps AccessibilitySnapshot's annotation renderer in the test-only `SnapshotKitTesting` product.** It does not link into shipping UI modules. - **Accessibility configs only render as snapshot tests.** Do not "fix" previews to include them without a preview-only dependency boundary. diff --git a/Shared/SnapshotKit/README.md b/Shared/SnapshotKit/README.md index aff3e1a5a..fcc59b09f 100644 --- a/Shared/SnapshotKit/README.md +++ b/Shared/SnapshotKit/README.md @@ -13,27 +13,33 @@ capture + comparison pipeline lives in the sibling ## What's in the box -- **`SnapshotConfiguration`** — one rendering variant: color scheme, Dynamic - Type size, contrast, layout direction (`rtl` token), legibility weight (bold - text, `bold` token), a device `Frame`, and a `snapshotType` (`.standard` or - `.accessibility`). `Hashable`, with an `identifier` (built from - `identifierParts`) that **omits default axes** so common cases stay terse. +- **`SnapshotConfiguration`** — one rendering variant. It specifies appearance + traits, device layout traits, a device `Frame`, and a `snapshotType`. + Appearance traits include color scheme, Dynamic Type size, contrast, layout + direction, and legibility weight. This `Hashable` value has an `identifier` + that **omits default axes**, so common cases stay terse. The `snapshotType` + is `.standard` or `.accessibility`. Frames come in four sizing strategies: fixed device viewports (`.iPhone`, `.iPad`), the intrinsic `.component` frame, `.fullContent(name:width:)`, and `.fullContent2D(name:minimumSize:)`. The ordinary full-content frame has a fixed width and a height measured from the settled content. Use the explicit two-axis frame for spatial canvases that scroll in both dimensions. Full-width scrolling descendants drive the measured height while preserving surrounding - navigation, tab, sheet, search, and toolbar chrome. An bounded + navigation, tab, sheet, search, and toolbar chrome. A bounded or greedy production container that cannot converge must expose and snapshot its shared scrolling child directly, without snapshot-only layout behavior. The iPhone/iPad full-content presets retain their normal viewport height as a minimum and - grow when content is taller. custom full-content frames shrink-wrap unless + grow when content is taller. Custom full-content frames shrink-wrap unless given a minimum. A frame also carries `safeAreaInsets` (default zero, keeping - images device-independent). the `.iPhoneNotched` preset simulates real device + images device-independent). The `.iPhoneNotched` preset simulates real device chrome (Dynamic Island top 47pt, home-indicator bottom 34pt) for cases that must prove layout under it. +- **`SnapshotConfiguration.LayoutTraits`** — an explicit interface idiom and + size-class set for adaptive content. The standard presets cover phone + portrait, phone landscape, and tablet portrait. Each set adds a stable + identifier token. If this value is `nil`, the capture inherits the host + simulator traits. - **`combinations(...)` + presets** (`.componentDefaults`, `.screenDefaults`, `.fullContentScreenDefaults`) — expand a terse declaration into the full matrix. @@ -80,10 +86,9 @@ capture + comparison pipeline lives in the sibling again before capture. The preview cutsheet ignores the hook (only the test pipeline can re-settle around it). - **`snapshotTraits(_:)`** — applies a configuration's traits to a view for the - preview cutsheet (color scheme, Dynamic Type, layout direction, legibility - weight, and an increased-contrast trait override), so previews and test - captures stay in lockstep. Simulated frame insets are capture-only — a - preview can't fake safe areas. + preview cutsheet. This includes explicit device layout traits. Thus, previews + and test captures stay in lockstep. Simulated frame insets are capture-only. + A preview cannot simulate safe areas. - **`\.isCapturingSnapshot`** — an environment flag that is `true` while `SnapshotKitTesting` captures the view (and in the preview cutsheet, which mirrors the tests). A view may read it **only** to render a deterministic diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift index 36df018a7..5b816dce4 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift @@ -5,7 +5,7 @@ extension SnapshotConfiguration { /// configurations. An empty axis falls back to that axis's default singleton /// (light color scheme, `.large` Dynamic Type, standard contrast, the /// component frame, a standard capture), so a caller varies only the axes it - /// cares about. + /// cares about. An empty layout-traits axis inherits the host traits. public static func combinations( devices: [Frame] = [], colorSchemes: [ColorScheme] = [], @@ -13,6 +13,7 @@ extension SnapshotConfiguration { contrasts: [ColorSchemeContrast] = [], layoutDirections: [LayoutDirection] = [], legibilityWeights: [LegibilityWeight] = [], + layoutTraits requestedLayoutTraits: [LayoutTraits] = [], snapshotTypes: [SnapshotType] = [], ) -> [SnapshotConfiguration] { let devices = devices.isEmpty ? [.component] : devices @@ -21,6 +22,9 @@ extension SnapshotConfiguration { let contrasts = contrasts.isEmpty ? [.standard] : contrasts let layoutDirections = layoutDirections.isEmpty ? [.leftToRight] : layoutDirections let legibilityWeights = legibilityWeights.isEmpty ? [.regular] : legibilityWeights + let layoutTraits: [LayoutTraits?] = requestedLayoutTraits.isEmpty + ? [nil] + : requestedLayoutTraits.map(Optional.some) let snapshotTypes = snapshotTypes.isEmpty ? [.standard] : snapshotTypes var result: [SnapshotConfiguration] = [] @@ -30,18 +34,21 @@ extension SnapshotConfiguration { for contrast in contrasts { for layoutDirection in layoutDirections { for legibilityWeight in legibilityWeights { - for snapshotType in snapshotTypes { - result.append( - SnapshotConfiguration( - colorScheme: colorScheme, - dynamicType: dynamicType, - contrast: contrast, - layoutDirection: layoutDirection, - legibilityWeight: legibilityWeight, - device: device, - snapshotType: snapshotType, - ), - ) + for layoutTraits in layoutTraits { + for snapshotType in snapshotTypes { + result.append( + SnapshotConfiguration( + colorScheme: colorScheme, + dynamicType: dynamicType, + contrast: contrast, + layoutDirection: layoutDirection, + legibilityWeight: legibilityWeight, + layoutTraits: layoutTraits, + device: device, + snapshotType: snapshotType, + ), + ) + } } } } diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift index e9d141a9c..b73ad9a0d 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift @@ -3,11 +3,12 @@ import UIKit extension SnapshotConfiguration { /// A `UITraitCollection` expressing this configuration's appearance axes — - /// interface style, content size category, contrast, layout direction, and - /// legibility weight. Used both by the preview cutsheet's trait override and - /// by the test runner to configure the capture, so the two stay in lockstep. + /// interface style, content size category, contrast, layout direction, + /// legibility weight, and any explicit device-adaptive traits. Used both by + /// the preview cutsheet's trait override and by the test runner to configure + /// the capture, so the two stay in lockstep. public var uiTraitCollection: UITraitCollection { - UITraitCollection(traitsFrom: [ + var traits = [ UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light), UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(dynamicType)), UITraitCollection(accessibilityContrast: contrast == .increased ? .high : .normal), @@ -17,7 +18,33 @@ extension SnapshotConfiguration { UITraitCollection( legibilityWeight: legibilityWeight == .bold ? .bold : .regular, ), - ]) + ] + if let layoutTraits { + traits.append(contentsOf: [ + UITraitCollection(userInterfaceIdiom: layoutTraits.interfaceIdiom.uiValue), + UITraitCollection(horizontalSizeClass: layoutTraits.horizontalSizeClass.uiValue), + UITraitCollection(verticalSizeClass: layoutTraits.verticalSizeClass.uiValue), + ]) + } + return UITraitCollection(traitsFrom: traits) + } +} + +extension SnapshotConfiguration.LayoutTraits.InterfaceIdiom { + fileprivate var uiValue: UIUserInterfaceIdiom { + switch self { + case .phone: .phone + case .tablet: .pad + } + } +} + +extension SnapshotConfiguration.LayoutTraits.SizeClass { + fileprivate var uiValue: UIUserInterfaceSizeClass { + switch self { + case .compact: .compact + case .regular: .regular + } } } diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift index cf448767b..b623185e6 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift @@ -7,10 +7,9 @@ import SwiftUI /// /// `Hashable` so it can key a matrix, and it vends an ``identifier`` (from /// ``identifierParts``) that names the reference image. The identifier **omits -/// default axes** — only a non-default color scheme, Dynamic Type size, contrast, -/// snapshot type, or a named device shows up — so the common (light / large / -/// standard) baseline stays terse. Treat the omission rules as a wire format: -/// changing them renames every reference image on disk. +/// default axes**. A named device, explicit layout traits, or a non-default +/// appearance axis adds a token. Thus, the common baseline stays terse. Treat +/// the omission rules as a wire format. A change renames reference images. public struct SnapshotConfiguration: Hashable, Sendable { /// The color scheme (light/dark) to render in. public var colorScheme: ColorScheme @@ -22,6 +21,9 @@ public struct SnapshotConfiguration: Hashable, Sendable { public var layoutDirection: LayoutDirection /// The legibility weight (regular / bold text) to render with. public var legibilityWeight: LegibilityWeight + /// Optional device-adaptive traits. `nil` inherits the host simulator's + /// idiom and size classes. + public var layoutTraits: LayoutTraits? /// The frame (size + name) to render into. public var device: Frame /// Whether this is a plain image or a VoiceOver-annotated accessibility image. @@ -36,6 +38,7 @@ public struct SnapshotConfiguration: Hashable, Sendable { contrast: ColorSchemeContrast = .standard, layoutDirection: LayoutDirection = .leftToRight, legibilityWeight: LegibilityWeight = .regular, + layoutTraits: LayoutTraits? = nil, device: Frame = .component, snapshotType: SnapshotType = .standard, name: String? = nil, @@ -45,6 +48,7 @@ public struct SnapshotConfiguration: Hashable, Sendable { self.contrast = contrast self.layoutDirection = layoutDirection self.legibilityWeight = legibilityWeight + self.layoutTraits = layoutTraits self.device = device self.snapshotType = snapshotType self.name = name @@ -56,6 +60,7 @@ public struct SnapshotConfiguration: Hashable, Sendable { var parts: [String] = [] if let name, !name.isEmpty { parts.append(name) } if !device.name.isEmpty { parts.append(device.name) } + if let layoutTraits { parts.append(layoutTraits.snapshotToken) } if colorScheme == .dark { parts.append("dark") } if dynamicType != .large { parts.append(dynamicType.snapshotToken) } if contrast == .increased { parts.append("contrast") } @@ -74,6 +79,56 @@ public struct SnapshotConfiguration: Hashable, Sendable { } extension SnapshotConfiguration { + /// A deterministic device idiom and size-class combination for adaptive + /// content. Omit it when the capture should inherit the host simulator. + public struct LayoutTraits: Hashable, Sendable { + public enum InterfaceIdiom: Hashable, Sendable { + case phone + case tablet + } + + public enum SizeClass: Hashable, Sendable { + case compact + case regular + } + + public var interfaceIdiom: InterfaceIdiom + public var horizontalSizeClass: SizeClass + public var verticalSizeClass: SizeClass + + public init( + interfaceIdiom: InterfaceIdiom, + horizontalSizeClass: SizeClass, + verticalSizeClass: SizeClass, + ) { + self.interfaceIdiom = interfaceIdiom + self.horizontalSizeClass = horizontalSizeClass + self.verticalSizeClass = verticalSizeClass + } + + public static let phonePortrait = LayoutTraits( + interfaceIdiom: .phone, + horizontalSizeClass: .compact, + verticalSizeClass: .regular, + ) + + public static let phoneLandscape = LayoutTraits( + interfaceIdiom: .phone, + horizontalSizeClass: .compact, + verticalSizeClass: .compact, + ) + + public static let tabletPortrait = LayoutTraits( + interfaceIdiom: .tablet, + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + ) + + fileprivate var snapshotToken: String { + "\(interfaceIdiom.snapshotToken)-\(horizontalSizeClass.snapshotToken)-\(verticalSizeClass.snapshotToken)" + } + } + /// Whether this configuration is a plain image or a VoiceOver-annotated one. public enum SnapshotType: Hashable, Sendable { case standard @@ -227,6 +282,24 @@ extension SnapshotConfiguration { } } +extension SnapshotConfiguration.LayoutTraits.InterfaceIdiom { + fileprivate var snapshotToken: String { + switch self { + case .phone: "phone" + case .tablet: "tablet" + } + } +} + +extension SnapshotConfiguration.LayoutTraits.SizeClass { + fileprivate var snapshotToken: String { + switch self { + case .compact: "compact" + case .regular: "regular" + } + } +} + extension DynamicTypeSize { /// A short, stable token for identifiers. Only non-`.large` values ever reach /// a filename (the default is omitted), but the full map keeps tokens unique. diff --git a/Shared/SnapshotKit/Sources/SnapshotTraits.swift b/Shared/SnapshotKit/Sources/SnapshotTraits.swift index 2d1e12124..e2c1633fa 100644 --- a/Shared/SnapshotKit/Sources/SnapshotTraits.swift +++ b/Shared/SnapshotKit/Sources/SnapshotTraits.swift @@ -6,39 +6,62 @@ extension View { /// preview renders the way its snapshot will be captured. /// /// Color scheme, Dynamic Type, layout direction, and legibility weight go - /// through the SwiftUI environment; increased contrast has no SwiftUI setter, - /// so that variant is hosted once through a UIKit trait override (which the - /// hosting controller bridges back into the content's `colorSchemeContrast`). + /// through the SwiftUI environment. Increased contrast and device-adaptive + /// traits use a UIKit trait override, which the hosting controller bridges + /// back into the SwiftUI content. @ViewBuilder public func snapshotTraits(_ configuration: SnapshotConfiguration) -> some View { let base = environment(\.colorScheme, configuration.colorScheme) .dynamicTypeSize(configuration.dynamicType) .environment(\.layoutDirection, configuration.layoutDirection) .environment(\.legibilityWeight, configuration.legibilityWeight) - if configuration.contrast == .increased { - ContrastOverrideHost { base } + if configuration.contrast == .increased || configuration.layoutTraits != nil { + SnapshotTraitOverrideHost(configuration: configuration) { base } } else { base } } } -/// Hosts content with an increased-contrast trait override — the only appearance -/// axis SwiftUI can't set directly. Sized to its content so it lays out inline in -/// the preview cutsheet. -private struct ContrastOverrideHost: UIViewControllerRepresentable { +/// Hosts content with UIKit-only trait overrides. Sized to its content so it +/// lays out inline in the preview cutsheet. +private struct SnapshotTraitOverrideHost: UIViewControllerRepresentable { + let configuration: SnapshotConfiguration @ViewBuilder var content: () -> Content func makeUIViewController(context _: Context) -> UIHostingController { let host = UIHostingController(rootView: content()) host.view.backgroundColor = .clear host.sizingOptions = [.intrinsicContentSize] - host.traitOverrides.accessibilityContrast = .high + applyTraits(to: host) return host } func updateUIViewController(_ host: UIHostingController, context _: Context) { host.rootView = content() - host.traitOverrides.accessibilityContrast = .high + applyTraits(to: host) + } + + private func applyTraits(to host: UIHostingController) { + applySnapshotTraitOverrides(configuration, to: host) + } +} + +/// Updates the UIKit-only traits and removes device overrides that no longer apply. +@MainActor +func applySnapshotTraitOverrides( + _ configuration: SnapshotConfiguration, + to host: UIViewController, +) { + let traits = configuration.uiTraitCollection + host.traitOverrides.accessibilityContrast = traits.accessibilityContrast + if configuration.layoutTraits != nil { + host.traitOverrides.userInterfaceIdiom = traits.userInterfaceIdiom + host.traitOverrides.horizontalSizeClass = traits.horizontalSizeClass + host.traitOverrides.verticalSizeClass = traits.verticalSizeClass + } else { + host.traitOverrides.remove(UITraitUserInterfaceIdiom.self) + host.traitOverrides.remove(UITraitHorizontalSizeClass.self) + host.traitOverrides.remove(UITraitVerticalSizeClass.self) } } diff --git a/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift b/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift index 00b605990..39aa706d0 100644 --- a/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift +++ b/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift @@ -27,6 +27,16 @@ struct SnapshotConfigurationTests { #expect(configs.count == 8) } + @Test func combinationsMultiplyAdaptiveLayoutTraits() { + let configs = SnapshotConfiguration.combinations( + devices: [.iPhone, .iPad], + layoutTraits: [.phonePortrait, .tabletPortrait], + ) + + #expect(configs.count == 4) + #expect(Set(configs.compactMap(\.layoutTraits)) == [.phonePortrait, .tabletPortrait]) + } + @Test func componentDefaultsAreAdditiveNotCartesian() { // baseline + dark + accessibility type size + increased contrast + a11y capture #expect([SnapshotConfiguration].componentDefaults.count == 5) @@ -95,6 +105,22 @@ struct SnapshotConfigurationTests { #expect(SnapshotConfiguration(device: .iPhoneNotched).identifierParts == ["iPhoneNotched"]) } + @Test func adaptiveLayoutTraitsHaveStableDistinctIdentifierTokens() { + let phonePortrait = SnapshotConfiguration(layoutTraits: .phonePortrait) + let phoneLandscape = SnapshotConfiguration(layoutTraits: .phoneLandscape) + let tabletPortrait = SnapshotConfiguration(layoutTraits: .tabletPortrait) + + #expect(phonePortrait.identifierParts == ["phone-compact-regular"]) + #expect(phoneLandscape.identifierParts == ["phone-compact-compact"]) + #expect(tabletPortrait.identifierParts == ["tablet-regular-regular"]) + #expect(Set([ + phonePortrait.identifier, + phoneLandscape.identifier, + tabletPortrait.identifier, + ]) + .count == 3) + } + @Test func identifierOrdersAndJoinsPartsWithNameFirst() { let config = SnapshotConfiguration( colorScheme: .dark, diff --git a/Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift b/Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift new file mode 100644 index 000000000..ec5abbaef --- /dev/null +++ b/Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift @@ -0,0 +1,36 @@ +@testable import SnapshotKit +import SwiftUI +import Testing +import UIKit + +@MainActor +struct SnapshotTraitsTests { + @Test func removesDeviceOverridesWhenAReusedHostReturnsToInheritedTraits() { + let host = UIHostingController(rootView: EmptyView()) + applySnapshotTraitOverrides( + SnapshotConfiguration( + contrast: .increased, + layoutTraits: .tabletPortrait, + ), + to: host, + ) + + #expect(host.traitCollection.accessibilityContrast == .high) + #expect(host.traitCollection.userInterfaceIdiom == .pad) + #expect(host.traitCollection.horizontalSizeClass == .regular) + #expect(host.traitCollection.verticalSizeClass == .regular) + #expect(host.traitOverrides.contains(UITraitUserInterfaceIdiom.self)) + #expect(host.traitOverrides.contains(UITraitHorizontalSizeClass.self)) + #expect(host.traitOverrides.contains(UITraitVerticalSizeClass.self)) + + applySnapshotTraitOverrides( + SnapshotConfiguration(contrast: .increased), + to: host, + ) + + #expect(host.traitCollection.accessibilityContrast == .high) + #expect(host.traitOverrides.contains(UITraitUserInterfaceIdiom.self) == false) + #expect(host.traitOverrides.contains(UITraitHorizontalSizeClass.self) == false) + #expect(host.traitOverrides.contains(UITraitVerticalSizeClass.self) == false) + } +} diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index 3a9321688..02a1dc863 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -21,6 +21,8 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. ## Invariants an agent can't re-derive - **The rendering pipeline is one async function.** All captures (standard and accessibility) flow through `renderSnapshotImage(...)`. +- **The hosted PNG API uses the same pipeline and capture lock.** It returns bytes and dimensions without comparing a reference. +- **Keep PNG export hosted.** `captureSnapshotPNG` requires `StuffTestHost`'s key window and must not gain a headless renderer. - **Its `async` is load-bearing.** A synchronous `Snapshotting` pullback could never settle `.task`-driven content. - **Accessibility annotations use AccessibilitySnapshot's SwiftUI renderer.** Keep the focused `AccessibilitySnapshotCore` + `AccessibilitySnapshotPreviews` products. - **Raised-floor accessibility captures parse twice.** Settle between passes and keep only the second render (`AccessibilitySnapshotViewControllerTests`). diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index d6c8b3786..b76c927a7 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -106,6 +106,20 @@ Reference images are written next to the test file under `__Snapshots__/` and are stored in Git LFS (see the root `.gitattributes`). Recording a new image is a failure by design, so a run that records can't be mistaken for a pass. +## Hosted PNG capture + +`captureSnapshotPNG` uses the same hosted renderer without comparing a +reference image. It accepts the view, configuration, name, sizing strategy, +safe-area insets, readiness hooks, and settle policy. The returned +`SnapshotPNG` contains PNG data, point and pixel dimensions, and image scale. + +The operation holds the existing capture lock. It also applies snapshot traits, +including explicit interface idiom and size classes. It then does full-content +measurement, accessibility annotation, settling, and PNG round-tripping. Call +it only from a test bundle hosted by `StuffTestHost`. It needs the host app's +key window. A rendering or encoding error throws. The caller must not publish +a partial artifact. + ## Recording `assertSnapshots` defaults to the `.missing` mode (records only images that diff --git a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift index 9f6b949a7..1eb0ca22c 100644 --- a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift +++ b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift @@ -47,6 +47,7 @@ public func assertSnapshots( return } for snapshotCase in snapshots { + guard Task.isCancelled == false else { return } await assertSnapshots( of: snapshotCase.content, named: snapshotCase.name, @@ -119,6 +120,7 @@ public func assertSnapshots( let isDiffReportingEnabled = SnapshotDiffReporting.isEnabledByEnvironment for configuration in configurations { + guard Task.isCancelled == false else { return } let hostingController = makeHostingController(for: view, configuration: configuration) let sizing: SnapshotSizing = switch configuration.device.size { case .fixed: @@ -305,57 +307,3 @@ private func simulatorMatchesSnapshotExpectations() -> Bool { } return true } - -/// Builds a hosting controller for `view` with the configuration's appearance -/// traits applied and a starting frame set. Dynamic Type, color scheme, layout -/// direction, and legibility weight are applied through the SwiftUI environment -/// (so measurement reflects them) and mirrored onto UIKit trait overrides (for -/// any embedded UIKit); increased contrast — which SwiftUI can't set — is a -/// trait override only. Intrinsic components get only their width here; the -/// pipeline measures their height after the content settles. -/// -/// SwiftUI transaction animations are disabled at the root: every state change -/// in the hosted tree commits its end state instantly instead of animating, so -/// finite time-based reveals no longer "run to completion" during settle — there -/// is no mid-flight frame to catch. The settle loop remains for `.task`-driven -/// async content, which still needs real suspension time to load. -@MainActor -private func makeHostingController( - for view: some View, - configuration: SnapshotConfiguration, -) -> UIViewController { - let styled = view - .environment(\.colorScheme, configuration.colorScheme) - .dynamicTypeSize(configuration.dynamicType) - .environment(\.layoutDirection, configuration.layoutDirection) - .environment(\.legibilityWeight, configuration.legibilityWeight) - .transaction { - $0.disablesAnimations = true - $0.animation = nil - } - let hostingController = UIHostingController(rootView: styled) - hostingController.view.backgroundColor = .clear - - let traits = configuration.uiTraitCollection - hostingController.traitOverrides.userInterfaceStyle = traits.userInterfaceStyle - hostingController.traitOverrides.preferredContentSizeCategory = traits - .preferredContentSizeCategory - hostingController.traitOverrides.accessibilityContrast = traits.accessibilityContrast - hostingController.traitOverrides.layoutDirection = traits.layoutDirection - hostingController.traitOverrides.legibilityWeight = traits.legibilityWeight - - switch configuration.device.size { - case let .fixed(size): - hostingController.view.frame = CGRect(origin: .zero, size: size) - case let .intrinsic(maxWidth): - let width = maxWidth ?? UIScreen.main.bounds.width - hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: 1) - case let .fullContent(width, minimumHeight): - let height = minimumHeight ?? 1 - hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: height) - case let .fullContent2D(minimumSize): - hostingController.view.frame = CGRect(origin: .zero, size: minimumSize) - } - - return hostingController -} diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift b/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift index 21f9e4a87..bc9722733 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift @@ -12,10 +12,10 @@ import Foundation /// starts while another is in flight parks here and runs when the first /// finishes, in FIFO order. /// -/// Waiting is not cancellation-aware by design: captures are bounded (a few -/// seconds), and a cancelled test's capture simply runs to completion when its -/// turn comes — the extra work is preferable to a cancellation path that could -/// leak the lock. +/// Waiting is not cancellation-aware by design. A cancelled waiter stays in the +/// FIFO so its continuation cannot strand the lock. When its turn arrives, the +/// rendering pipeline observes cancellation before it hosts content, then this +/// type releases the lock normally. /// /// Re-entering from a task that already holds the lock (an `onReadyToSnapshot` /// hook rendering another snapshot) would deadlock, so it traps as a diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift b/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift new file mode 100644 index 000000000..d70948e3c --- /dev/null +++ b/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift @@ -0,0 +1,50 @@ +import SnapshotKit +import SwiftUI +import UIKit + +/// Builds a host with the configuration's SwiftUI and UIKit traits. +@MainActor +func makeHostingController( + for view: some View, + configuration: SnapshotConfiguration, +) -> UIViewController { + let styled = view + .environment(\.colorScheme, configuration.colorScheme) + .dynamicTypeSize(configuration.dynamicType) + .environment(\.layoutDirection, configuration.layoutDirection) + .environment(\.legibilityWeight, configuration.legibilityWeight) + .transaction { + $0.disablesAnimations = true + $0.animation = nil + } + let hostingController = UIHostingController(rootView: styled) + hostingController.view.backgroundColor = .clear + + let traits = configuration.uiTraitCollection + hostingController.traitOverrides.userInterfaceStyle = traits.userInterfaceStyle + hostingController.traitOverrides.preferredContentSizeCategory = traits + .preferredContentSizeCategory + hostingController.traitOverrides.accessibilityContrast = traits.accessibilityContrast + hostingController.traitOverrides.layoutDirection = traits.layoutDirection + hostingController.traitOverrides.legibilityWeight = traits.legibilityWeight + if configuration.layoutTraits != nil { + hostingController.traitOverrides.userInterfaceIdiom = traits.userInterfaceIdiom + hostingController.traitOverrides.horizontalSizeClass = traits.horizontalSizeClass + hostingController.traitOverrides.verticalSizeClass = traits.verticalSizeClass + } + + switch configuration.device.size { + case let .fixed(size): + hostingController.view.frame = CGRect(origin: .zero, size: size) + case let .intrinsic(maxWidth): + let width = maxWidth ?? UIScreen.main.bounds.width + hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: 1) + case let .fullContent(width, minimumHeight): + let height = minimumHeight ?? 1 + hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: height) + case let .fullContent2D(minimumSize): + hostingController.view.frame = CGRect(origin: .zero, size: minimumSize) + } + + return hostingController +} diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift index 5fe9b46d1..4b40b4d12 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift @@ -58,6 +58,20 @@ public enum SnapshotRenderingError: Error, Equatable, Sendable { maximumPixelDimension: Int, maximumPixelCount: Int, ) + /// The rendered image could not be encoded as PNG bytes. + case pngEncodingFailed(name: String) + /// The PNG round-trip did not produce a Core Graphics image. + case missingCGImage(name: String) + /// The hosted pixels were still changing when the settle budget ended. + case settleTimedOut(name: String, phase: String, viewType: String, budget: TimeInterval) + /// The host could not complete enough render passes to prove stability. + case settleStarved( + name: String, + phase: String, + viewType: String, + passes: Int, + cap: TimeInterval, + ) } extension SnapshotRenderingError: LocalizedError { @@ -86,6 +100,14 @@ extension SnapshotRenderingError: LocalizedError { maximumPixelCount, ): return "Snapshot \(name) would render at \(pixelWidth)×\(pixelHeight) pixels; two-axis captures are limited to \(maximumPixelDimension) pixels per dimension and \(maximumPixelCount) pixels total." + case let .pngEncodingFailed(name): + return "Snapshot \(name) could not be encoded as a PNG image." + case let .missingCGImage(name): + return "Snapshot \(name) did not produce a Core Graphics image." + case let .settleTimedOut(name, phase, viewType, budget): + return "Snapshot \(name) never settled during \(phase) for \(viewType) within \(budget.formatted())s. Freeze endless motion behind `\\.isCapturingSnapshot`, or raise the settle floor for finite work." + case let .settleStarved(name, phase, viewType, passes, cap): + return "Snapshot \(name) settle starved during \(phase) for \(viewType): only \(passes) render passes completed in \(cap.formatted())s." } } } @@ -203,7 +225,8 @@ public func renderSnapshotImage( timing: SnapshotCaptureTiming, ) async throws -> SnapshotCapture { try await SnapshotCaptureLock.withLock { - try await renderSnapshotImageLocked( + try Task.checkCancellation() + return try await renderSnapshotImageLocked( of: viewController, named: name, sizing: sizing, @@ -292,7 +315,7 @@ private func renderSnapshotImageLocked( } defer { removeChildAfterCapture(wrappingViewController) } - await reportIfUnsettled( + try await throwIfUnsettled( timing.measure(.settle) { await settleForCapture( wrappingViewController.view, @@ -311,7 +334,8 @@ private func renderSnapshotImageLocked( // effects (a focused field, a presented state) are settled before the // accessibility parse and capture below reflect them. if let onReadyToSnapshot { - await reportIfUnsettled( + try Task.checkCancellation() + try await throwIfUnsettled( timing.measure(.hook) { await onReadyToSnapshot() wrappingViewController.view.setNeedsLayout() @@ -354,7 +378,7 @@ private func renderSnapshotImageLocked( timing.measure(.accessibilityParse) { parseAccessibility() } - await reportIfUnsettled( + try await throwIfUnsettled( timing.measure(.settle) { await settleForCapture( wrappingViewController.view, @@ -374,6 +398,7 @@ private func renderSnapshotImageLocked( } } + try Task.checkCancellation() viewController.view.hideTextInputCursors() timing.measure(.drain) { drainInFlightAnimations() } @@ -383,11 +408,11 @@ private func renderSnapshotImageLocked( // Round-trip through PNG bytes (preserving scale, which `UIImage(data:)` // alone would reset to 1) so the compare and the disk artifact are the // same bytes — see the doc comment above. - return timing.measure(.pngRoundTrip) { + return try timing.measure(.pngRoundTrip) { guard let pngData = image.pngData(), let decoded = UIImage(data: pngData, scale: image.scale) else { - preconditionFailure("Snapshot capture could not be PNG-encoded.") + throw SnapshotRenderingError.pngEncodingFailed(name: name) } return SnapshotCapture(image: decoded, pngData: pngData) } @@ -521,7 +546,7 @@ private func resolveContentSize( } } - await reportIfUnsettled( + try await throwIfUnsettled( timing.measure(.intrinsicMeasure) { await settleForCapture( probeWrapper.view, diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift b/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift index 862432d02..0f592765d 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift @@ -15,6 +15,7 @@ private enum SnapshotMeasurementHookResult { maximumDuration: TimeInterval, hook: @MainActor @escaping () async -> Void, ) async throws { + try Task.checkCancellation() let result = await withTaskGroup(of: SnapshotMeasurementHookResult.self) { group in group.addTask { await hook() diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift b/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift new file mode 100644 index 000000000..71a822eab --- /dev/null +++ b/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift @@ -0,0 +1,59 @@ +import SnapshotKit +import SwiftUI +import TestHostSupport +import UIKit + +/// PNG bytes and dimensions from a hosted capture without a reference compare. +public struct SnapshotPNG: Sendable { + public let data: Data + public let pointSize: CGSize + public let pixelSize: CGSize + public let scale: CGFloat + + public init(data: Data, pointSize: CGSize, pixelSize: CGSize, scale: CGFloat) { + self.data = data + self.pointSize = pointSize + self.pixelSize = pixelSize + self.scale = scale + } +} + +/// Captures a configured SwiftUI view through the hosted snapshot pipeline. +@MainActor +public func captureSnapshotPNG( + of view: some View, + configuration: SnapshotConfiguration, + named name: String, + sizing: SnapshotSizing, + safeAreaInsets: UIEdgeInsets?, + measurementReadiness: SnapshotMeasurementReadiness, + onReadyToMeasure: (@MainActor () async -> Void)?, + settle: SnapshotSettle, + onReadyToSnapshot: (@MainActor () async -> Void)?, +) async throws -> SnapshotPNG { + try waitFor { hostKeyWindow()?.rootViewController != nil } + let controller = makeHostingController(for: view, configuration: configuration) + let timeoutPolicy = try SnapshotSettleTimeoutPolicy.fromEnvironment() + let capture = try await renderSnapshotCapture( + of: controller, + named: name, + sizing: sizing, + safeAreaInsets: safeAreaInsets, + isAccessibility: configuration.snapshotType == .accessibility, + measurementReadiness: measurementReadiness, + onReadyToMeasure: onReadyToMeasure, + settle: settle, + onReadyToSnapshot: onReadyToSnapshot, + settleTimeoutPolicy: timeoutPolicy, + timing: SnapshotCaptureTiming(identifier: name, isEnabled: false), + ) + guard let image = capture.image.cgImage else { + throw SnapshotRenderingError.missingCGImage(name: name) + } + return SnapshotPNG( + data: capture.pngData, + pointSize: capture.image.size, + pixelSize: CGSize(width: image.width, height: image.height), + scale: capture.image.scale, + ) +} diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift index ae1b831df..7497c7e74 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift @@ -1,6 +1,5 @@ import SnapshotKit import SwiftUI -import Testing import UIKit /// How a settle phase ended. Only ``settled`` and ``skipped`` mean the pixels @@ -26,48 +25,34 @@ import UIKit case cancelled } -/// Fails the test when a settle phase ended somewhere the capture can't be -/// trusted from. A view still in motion at the budget records an arbitrary -/// frame, which is precisely how a flaky reference lands — the failure class -/// the settle loop exists to prevent — so it's louder than a log: a silent -/// timeout is indistinguishable from a clean capture in CI output. +/// Throws when a settle phase cannot produce a trustworthy capture. @MainActor -func reportIfUnsettled( +func throwIfUnsettled( _ outcome: SettleOutcome, phase: String, of viewController: UIViewController, named name: String, -) { +) throws { switch outcome { case .settled, .skipped: return case let .timedOut(budget): - Issue.record( - """ - Snapshot content never settled: the \(phase) phase for "\(name)" \ - (\(type(of: viewController))) was observed still changing \(budget.formatted())s \ - after hosting, so this capture is an arbitrary frame of whatever is still moving. \ - Freeze the motion at a deterministic phase behind `\\.isCapturingSnapshot` (the \ - Where app does this with `MotionIsStatic`), or — if the content is merely slow \ - rather than endless — raise the floor with `.settledAtLeast(minDuration:)`. - """, + throw SnapshotRenderingError.settleTimedOut( + name: name, + phase: phase, + viewType: String(reflecting: type(of: viewController)), + budget: budget, ) case let .starved(passes, cap): - Issue.record( - """ - Snapshot settle starved: the \(phase) phase for "\(name)" \ - (\(type(of: viewController))) completed only \(passes) render pass(es) in \ - \(cap.formatted())s without ever observing the content change, so pixel \ - stability could not be confirmed. This is an environment problem (a machine too \ - loaded to complete render passes) or a view that renders no pixels (a zero-sized \ - frame) — not view motion; widening the settle budget won't fix it. - """, + throw SnapshotRenderingError.settleStarved( + name: name, + phase: phase, + viewType: String(reflecting: type(of: viewController)), + passes: passes, + cap: cap, ) case .cancelled: - // Deliberately quiet: a cancelled test is already being torn down and - // a second issue would just bury the cancellation. Skipping the assert - // outright is the real fix, tracked in TODOs.md. - return + throw CancellationError() } } @@ -106,6 +91,7 @@ func settleForCapture( ) case .immediate: await Task.yield() + guard Task.isCancelled == false else { return .cancelled } CATransaction.performWithoutAnimation(view.layoutIfNeeded) return .skipped } diff --git a/Shared/SnapshotKitTesting/TODOs.md b/Shared/SnapshotKitTesting/TODOs.md index 825942b81..e5ff96747 100644 --- a/Shared/SnapshotKitTesting/TODOs.md +++ b/Shared/SnapshotKitTesting/TODOs.md @@ -19,12 +19,12 @@ The item format and placement rule live in the root ## P2s (Nice to have) - perf [needs-design]: A byte-equality fast path around `assertSnapshot` was measured and **declined** — recorded so it isn't re-proposed without new numbers. 49 of 52 captures are byte-identical to their references, so the hit rate is there, but the comparison is only ~7% of a capture (mean 35ms) once the drain stall is gone, capping the win at ~6% of the suite. Paying for it means letting `snapshotReferenceURL`'s replication of swift-snapshot-testing's private layout gate the pass/fail verdict: a wrong path there currently degrades to a `referenceMissing` diff line (harmless), but on the verdict path it would skip a real comparison and read as a pass. Revisit if the comparison's share grows or the library exposes its reference URL. (agent) -- fix [quick-win]: Cancellation mid-settle proceeds to capture and assert — `settleContent` now reports `.cancelled` but `reportIfUnsettled` deliberately stays quiet on it (`Sources/SnapshotRenderingSupport.swift:66-69`), so a cancelled test (e.g. a future time-limit trait) still captures half-settled content and records a spurious image mismatch on top of the cancellation. Propagate the outcome out of `renderSnapshotImage` so `assertSnapshots` can skip the comparison entirely, keeping cancelled tests clean. (pr review, July 2026) - fix [quick-win]: The duplicate-identifier guard only protects the provider overload of `assertSnapshots` (`Sources/AssertSnapshots.swift:36-47`) — the inline `assertSnapshots(of:named:configurations:)` overload (`:71-85`) accepts a `configurations` array containing duplicates and silently compares the second against the first's recording. Run the same guard over `[SnapshotCase(name:configurations:)]` there. (pr review, July 2026) # Completed issues +- fix: Cancellation mid-settle proceeded to capture and assert — settle validation now throws `CancellationError`, so `assertSnapshots` skips comparison and hosted PNG callers stop without publishing a half-settled image. (From the July 2026 snapshot-testing PR review.) (Resolved with the hosted PNG export API.) - fix: The reporting tests wrote **fabricated rows into the reports** — `./test --review` listed a reference that does not exist, at the top of the list, and `--timings` counted captures that never happened. `./test` recovers both channels by grepping `SNAPSHOT_DIFF` / `SNAPSHOT_TIMING` out of the run logs (and counts timing lines as images for the progress line), while `SnapshotDiffReporting.report(...)` and `SnapshotCaptureTiming.emit()` each encoded *and* printed in one function — so the tests pinning those wire formats emitted real lines. The diff fixture sorted first, because its numbers were borrowed from the genuine `swiftDataInspector` regression (max delta 203, 7430 pixels, 0.235%), which made the one row most demanding investigation the one that wasn't real; `SnapshotCaptureTimingTests` contributed five invented captures, so `./test --only SnapshotKitTestingTests --timings` reported "5 captures, 0.1s total, 0.024s per image" for a run that captured nothing, and `--everything --timings` blended those into the aggregate the suite's perf decisions are read off. The `SNAPSHOT_DIFF` env gate never helped: it is checked by the *pipeline*, not inside `report`. (Resolved: each channel is split so printing is the pipeline's alone and the payload is separately askable — `SnapshotDiffReporting.line(describing:…)`, `SnapshotCaptureTiming.line()`, and `SnapshotSettleReporting.line(…)` return the JSON without emitting, and all four test sites call those. `SnapshotSettleReporting` got the same treatment for symmetry, though nothing aggregates that channel yet. Verified both ways: a unit-only run now reports no timing lines and no differing captures, while a real snapshot run still produces the full phase breakdown and diff table. The rule is now an invariant in [`AGENTS.md`](AGENTS.md).) - refactor: Drop the unused `AccessibilitySnapshot` umbrella product dependency (root `Package.swift`, SnapshotKitTesting target) — only `AccessibilitySnapshotCore` is imported; the umbrella product just widens the statically-embedded closure of the consuming test bundle. (From the July 2026 snapshot-testing PR review.) (Resolved: the umbrella product is gone from the target's dependencies; only `AccessibilitySnapshotCore` remains, which is the sole import.) - fix: The settle timeout fired on starved-but-static content — under CPU starvation (a cold, loaded CI runner) a single settle pass (16ms sleep + layout + quarter-res render) cost over a second, so the three passes stability needs didn't fit the 2.5s budget and `settleContent` failed static content as "never settled" (~50% of cold CI runs on the About screen, whose capture still matched the reference; reproduced locally by duty-cycling SIGSTOP/SIGCONT on `StuffTestHost`). (Resolved: the budget now bounds *observed motion* — `.timedOut` requires a change seen past anchor establishment, a change-free loop keeps running until it proves stability, and a hard cap at 4× the budget gives up as the new `.starved` outcome naming the pass count. Settle failures also now name the full snapshot identifier via `renderSnapshotImage(of:named:...)`, so a matrix timeout says which configuration. Guarded by `SnapshotRenderingSupportTests`.) - refactor: Replace wall-clock `Date()` deadlines in `settleContent` and `drainInFlightAnimations` (`Sources/SnapshotRenderingSupport.swift`) with `ContinuousClock` — the modern, suspension-proof way to measure elapsed time per the concurrency skill. Behavior-neutral cleanup. (From the July 2026 snapshot-testing PR review.) (Resolved: both now measure with `ContinuousClock`, landed with the starved-settle fix above.) -- fix: The settle loop timed out silently — when content never reached pixel stability, `settleContent`'s loop just exited and the capture proceeded with a mid-animation frame, with nothing distinguishing "settled" from "gave up after 2.5s". (Resolved: `settleContent`/`settleForCapture` return a `SettleOutcome`, and `reportIfUnsettled` records an `Issue` naming the phase, the view controller, and the budget when the content never stopped moving. All 232 WhereUI references capture without a single timeout, so the failure path costs nothing today and catches the next un-frozen animation.) +- fix: The settle loop timed out silently — when content never reached pixel stability, `settleContent`'s loop just exited and the capture proceeded with a mid-animation frame, with nothing distinguishing "settled" from "gave up after 2.5s". (Resolved: settle validation now throws a typed error that names the phase, view controller, and budget. Snapshot assertions record it, while hosted PNG callers stop immediately. All 232 WhereUI references capture without a single timeout, so the failure path costs nothing today and catches the next un-frozen animation.) diff --git a/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift b/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift index 188726fa6..09c36a8fd 100644 --- a/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift +++ b/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift @@ -31,9 +31,78 @@ struct AssertSnapshotsTests { assertion.cancel() await assertion.value } + + @Test func cancelledProviderDoesNotBuildItsNextCase() async throws { + try waitFor { hostKeyWindow() != nil } + let probe = MeasurementHookCancellationProbe() + let hooks = ProviderCancellationHooks( + waitForCancellation: { + probe.didStart = true + while Task.isCancelled == false { + await Task.yield() + } + }, + didBuildSecondCase: { + probe.didBuildSecondCase = true + }, + ) + + await ProviderCancellationContext.$hooks.withValue(hooks) { + let assertion = Task { @MainActor in + await assertSnapshots(of: CancellationSnapshotProvider.self) + } + while probe.didStart == false { + await Task.yield() + } + assertion.cancel() + await assertion.value + } + + #expect(probe.didBuildSecondCase == false) + } } @MainActor private final class MeasurementHookCancellationProbe { var didStart = false + var didBuildSecondCase = false +} + +private struct ProviderCancellationHooks { + let waitForCancellation: @MainActor @Sendable () async -> Void + let didBuildSecondCase: @MainActor @Sendable () -> Void +} + +private enum ProviderCancellationContext { + @TaskLocal static var hooks: ProviderCancellationHooks? +} + +private struct CancellationSnapshotProvider: SnapshotProviding { + @MainActor static var snapshots: [SnapshotCase] { + let hooks = ProviderCancellationContext.hooks + SnapshotCase( + name: "cancelled-first-case", + configurations: [ + SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "intrinsic", + size: .intrinsic(maxWidth: 100), + ), + ), + ], + measurementReadiness: .immediate, + onReadyToMeasure: hooks?.waitForCancellation, + settle: .immediate, + ) { + Color.red.frame(height: 40) + } + SnapshotCase( + name: "must-not-build", + configurations: [SnapshotConfiguration()], + settle: .immediate, + ) { + let _ = hooks?.didBuildSecondCase() + Color.blue + } + } } diff --git a/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift b/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift index d27710ee7..113db5f12 100644 --- a/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift +++ b/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift @@ -11,7 +11,7 @@ import UIKit /// exchange is a parity toggle, and a second capture's swizzle un-swizzled the /// first's (the Phase 13 parallel experiment produced 24+ spurious mismatches /// this way). `renderSnapshotImage` now serializes captures through a FIFO -/// mutex, so concurrent calls must produce exactly the images serial calls do. +/// mutex, so concurrent hosted PNG calls must match serial calls exactly. /// /// The probe view paints its safe area red over a green backdrop that ignores /// it, so the rendered green strip *is* the effective top inset: capture A @@ -89,13 +89,24 @@ private struct SafeAreaProbeView: View { @MainActor private func captureProbeImage(topInset: CGFloat) async throws -> UIImage { - let host = UIHostingController(rootView: SafeAreaProbeView()) - host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - return try await renderSnapshotImage( - of: host, + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "safe-area-probe", + size: .fixed(CGSize(width: 100, height: 100)), + ), + ) + let png = try await captureSnapshotPNG( + of: SafeAreaProbeView(), + configuration: configuration, named: "safe-area-\(Int(topInset))pt-probe", + sizing: .fixed, safeAreaInsets: UIEdgeInsets(top: topInset, left: 0, bottom: 0, right: 0), + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .settled, + onReadyToSnapshot: nil, ) + return try #require(UIImage(data: png.data, scale: png.scale)) } private func expectations(for image: UIImage) -> ProbedCapture { diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift new file mode 100644 index 000000000..0399727cd --- /dev/null +++ b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift @@ -0,0 +1,394 @@ +@_spi(Testing) import SnapshotKitTesting +import SwiftUI +import Testing +import UIKit + +@MainActor +struct SnapshotPNGTests { + @Test func returnsPNGBytesAndPointAndPixelDimensions() async throws { + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "probe", + size: .fixed(CGSize(width: 80, height: 60)), + ), + ) + + let png = try await captureSnapshotPNG( + of: Color.red, + configuration: configuration, + named: "png-api-fixed-probe", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.data.isEmpty == false) + #expect(png.pointSize == CGSize(width: 80, height: 60)) + #expect(png.pixelSize.width == png.pointSize.width * png.scale) + #expect(png.pixelSize.height == png.pointSize.height * png.scale) + } + + @Test func appliesTabletLayoutTraitsToUIKitAndSwiftUI() async throws { + try await expectAdaptiveLayoutTraits( + .tabletPortrait, + expected: AdaptiveTraitExpectation( + interfaceIdiom: .pad, + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + ), + captureName: "png-api-tablet-traits-probe", + ) + } + + @Test func appliesPhoneLandscapeLayoutTraitsToUIKitAndSwiftUI() async throws { + try await expectAdaptiveLayoutTraits( + .phoneLandscape, + expected: AdaptiveTraitExpectation( + interfaceIdiom: .phone, + horizontalSizeClass: .compact, + verticalSizeClass: .compact, + ), + captureName: "png-api-phone-landscape-traits-probe", + ) + } + + @Test func runsReadinessHooksThroughTheSharedPipeline() async throws { + var measurementHookRan = false + var finalHookRan = false + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "intrinsic-probe", + size: .intrinsic(maxWidth: 100), + ), + ) + + _ = try await captureSnapshotPNG( + of: Color.blue.frame(height: 40), + configuration: configuration, + named: "png-api-hooks-probe", + sizing: .intrinsic(width: 100, minimumHeight: 0), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: { measurementHookRan = true }, + settle: .immediate, + onReadyToSnapshot: { finalHookRan = true }, + ) + + #expect(measurementHookRan) + #expect(finalHookRan) + } + + @Test func capturesFullHeightContent() async throws { + let content = ScrollView { + VStack(spacing: 0) { + Color.red.frame(height: 100) + Color.blue.frame(height: 100) + } + } + let configuration = SnapshotConfiguration( + device: .fullContent(name: "full-height-probe", width: 100, minimumHeight: 60), + ) + + let png = try await captureSnapshotPNG( + of: content, + configuration: configuration, + named: "png-api-full-height-probe", + sizing: .intrinsic(width: 100, minimumHeight: 60), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.pointSize == CGSize(width: 100, height: 200)) + } + + @Test func capturesTwoAxisFullContent() async throws { + let content = ScrollView([.horizontal, .vertical]) { + Color.green.frame(width: 180, height: 160) + } + let minimumSize = CGSize(width: 80, height: 60) + let configuration = SnapshotConfiguration( + device: .fullContent2D(name: "two-axis-probe", minimumSize: minimumSize), + ) + + let png = try await captureSnapshotPNG( + of: content, + configuration: configuration, + named: "png-api-two-axis-probe", + sizing: .fullContent2D(minimumSize: minimumSize), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.pointSize == CGSize(width: 180, height: 160)) + } + + @Test func capturesAccessibilityAnnotations() async throws { + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "accessibility-probe", + size: .fixed(CGSize(width: 240, height: 160)), + ), + snapshotType: .accessibility, + ) + + let png = try await captureSnapshotPNG( + of: Text("Atlas item").accessibilityLabel("Atlas accessibility item"), + configuration: configuration, + named: "png-api-accessibility-probe", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.data.isEmpty == false) + #expect(png.pointSize.width >= 240) + #expect(png.pointSize.height >= 160) + } + + @Test func cancelledQueuedCaptureDoesNotRunMeasurementHook() async throws { + let probe = QueuedCaptureCancellationProbe() + let fixedConfiguration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "queued-cancellation-holder", + size: .fixed(CGSize(width: 80, height: 60)), + ), + ) + let firstCapture = Task { @MainActor in + try await captureSnapshotPNG( + of: Color.red, + configuration: fixedConfiguration, + named: "queued-cancellation-holder", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: { + probe.firstCaptureStarted = true + while probe.canFinishFirstCapture == false { + await Task.yield() + } + }, + ) + } + while probe.firstCaptureStarted == false { + await Task.yield() + } + + let intrinsicConfiguration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "queued-cancellation-waiter", + size: .intrinsic(maxWidth: 80), + ), + ) + let queuedCapture = Task { @MainActor in + probe.queuedCaptureStarted = true + return try await captureSnapshotPNG( + of: Color.blue.frame(height: 60).onAppear { + probe.queuedContentAppeared = true + }, + configuration: intrinsicConfiguration, + named: "queued-cancellation-waiter", + sizing: .intrinsic(width: 80, minimumHeight: 0), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: { probe.measurementHookRan = true }, + settle: .immediate, + onReadyToSnapshot: nil, + ) + } + while probe.queuedCaptureStarted == false { + await Task.yield() + } + + queuedCapture.cancel() + probe.canFinishFirstCapture = true + _ = try await firstCapture.value + await #expect(throws: CancellationError.self) { + try await queuedCapture.value + } + #expect(probe.measurementHookRan == false) + #expect(probe.queuedContentAppeared == false) + } + + @Test func propagatesSettleFailures() async throws { + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "moving-probe", + size: .fixed(CGSize(width: 80, height: 60)), + ), + ) + + let error = await #expect(throws: SnapshotRenderingError.self) { + try await captureSnapshotPNG( + of: NonSettlingPNGView(), + configuration: configuration, + named: "png-api-moving-probe", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .settled, + onReadyToSnapshot: nil, + ) + } + guard case let .settleTimedOut(name, phase, _, _) = error else { + Issue.record("Expected a settle timeout, got \(String(describing: error)).") + return + } + #expect(name == "png-api-moving-probe") + #expect(phase == "content") + } +} + +@MainActor +private func expectAdaptiveLayoutTraits( + _ layoutTraits: SnapshotConfiguration.LayoutTraits, + expected: AdaptiveTraitExpectation, + captureName: String, +) async throws { + let configuration = SnapshotConfiguration( + layoutTraits: layoutTraits, + device: SnapshotConfiguration.Frame( + name: captureName, + size: .fixed(CGSize(width: 100, height: 60)), + ), + ) + let png = try await captureSnapshotPNG( + of: AdaptiveTraitProbe(expected: expected), + configuration: configuration, + named: captureName, + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + let image = try #require(UIImage(data: png.data, scale: png.scale)) + let swiftUITraits = image.probePixel(atUnitPoint: CGPoint(x: 0.25, y: 0.5)) + let uiKitTraits = image.probePixel(atUnitPoint: CGPoint(x: 0.75, y: 0.5)) + + #expect(swiftUITraits.green > 0.5) + #expect(swiftUITraits.red < 0.5) + #expect(uiKitTraits.green > 0.5) + #expect(uiKitTraits.red < 0.5) +} + +private struct AdaptiveTraitExpectation { + let interfaceIdiom: UIUserInterfaceIdiom + let horizontalSizeClass: UIUserInterfaceSizeClass + let verticalSizeClass: UIUserInterfaceSizeClass + + var swiftUIHorizontalSizeClass: UserInterfaceSizeClass { + switch horizontalSizeClass { + case .compact: .compact + case .regular: .regular + case .unspecified: .compact + @unknown default: .compact + } + } + + var swiftUIVerticalSizeClass: UserInterfaceSizeClass { + switch verticalSizeClass { + case .compact: .compact + case .regular: .regular + case .unspecified: .compact + @unknown default: .compact + } + } +} + +private struct AdaptiveTraitProbe: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + let expected: AdaptiveTraitExpectation + + var body: some View { + HStack(spacing: 0) { + let matches = horizontalSizeClass == expected.swiftUIHorizontalSizeClass + && verticalSizeClass == expected.swiftUIVerticalSizeClass + (matches ? Color.green : Color.red) + UIKitAdaptiveTraitProbe(expected: expected) + } + } +} + +private struct UIKitAdaptiveTraitProbe: UIViewRepresentable { + let expected: AdaptiveTraitExpectation + + func makeUIView(context _: Context) -> UIKitAdaptiveTraitProbeView { + UIKitAdaptiveTraitProbeView(expected: expected) + } + + func updateUIView(_ view: UIKitAdaptiveTraitProbeView, context _: Context) { + view.expected = expected + view.setNeedsLayout() + } +} + +@MainActor +private final class UIKitAdaptiveTraitProbeView: UIView { + var expected: AdaptiveTraitExpectation + + init(expected: AdaptiveTraitExpectation) { + self.expected = expected + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override func layoutSubviews() { + super.layoutSubviews() + let matches = traitCollection.userInterfaceIdiom == expected.interfaceIdiom + && traitCollection.horizontalSizeClass == expected.horizontalSizeClass + && traitCollection.verticalSizeClass == expected.verticalSizeClass + backgroundColor = matches ? .green : .red + } +} + +@MainActor +private final class QueuedCaptureCancellationProbe { + var firstCaptureStarted = false + var canFinishFirstCapture = false + var queuedCaptureStarted = false + var measurementHookRan = false + var queuedContentAppeared = false +} + +private struct NonSettlingPNGView: View { + @State private var isRed = false + + var body: some View { + (isRed ? Color.red : Color.blue) + .task { + while Task.isCancelled == false { + isRed.toggle() + do { + try await Task.sleep(for: .milliseconds(40)) + } catch is CancellationError { + return + } catch { + Issue.record(error) + return + } + } + } + } +} diff --git a/Tools/ADVERSARIAL_TEST_PLAN.md b/Tools/ADVERSARIAL_TEST_PLAN.md index 7c4033712..21e781a95 100644 --- a/Tools/ADVERSARIAL_TEST_PLAN.md +++ b/Tools/ADVERSARIAL_TEST_PLAN.md @@ -26,6 +26,7 @@ functions returned expected values. | `Where/install` | macOS/Xcode/device | signing and device inventory | physical device | Dry run performs no build/install/launch | | `Ledger/install` | macOS/Xcode | built/installed apps | `/Applications/Ledger.app` | Exact-process and transactional replacement | | `tla-check` | macOS/Linux-compatible tooling | manifests/specs | retained run artifacts | Pinned tools and honest pass/fail policy | +| `flyover` | macOS/Xcode for export, Python 3 for preview | catalog, generated atlas | generated directory | Export is atomic. Preview serves only validated files | Existing flags and their observable behavior are compared with `main`. New behavior must be called out explicitly; the planned additions are the @@ -134,6 +135,16 @@ After failure, exactly one state is legal: A mixture is never accepted. +### Flyover export and preview + +Cover unsafe output aliases, replacement races, invalid markers, incomplete +manifests, missing or extra images, symbolic links, path traversal, paths with +spaces, automatic ports, occupied ports, loopback and LAN binding, and clean +Control-C shutdown. + +The preview server must expose only files in its validated allowlist. It must +bind to loopback unless the user selects LAN access. + ### Installers For `Where/install`, cover unset teams and malformed mise configuration; diff --git a/Tools/README.md b/Tools/README.md index 2238e1c2b..e46c82653 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -13,6 +13,9 @@ result reporting without requiring Java in its tests. The Xcode-facing root commands keep process and simulator orchestration in shell; `xcode_results.py` shares xcresult traversal, `snapshot_reports.py` shares capture reports, and the command-specific Python modules retain each command's distinct policy. +`flyover_manifest.py` validates generated atlases and builds their file +allowlists. `flyover_preview.py` pins and serves those files from a local HTTP +server. Filesystem-heavy Ruby generators are require-safe so their behavior can be exercised against temporary repositories. `SimulatorRegistry` validates exact runtime ownership from captured `simctl` JSON and persists private claims diff --git a/Tools/Tests/flyover_test_support.py b/Tools/Tests/flyover_test_support.py new file mode 100644 index 000000000..574fce3b1 --- /dev/null +++ b/Tools/Tests/flyover_test_support.py @@ -0,0 +1,121 @@ +"""Shared fixtures for Flyover manifest and preview tool tests.""" + +import json +from pathlib import Path +from typing import Any + + +def write_manifest(root: Path, manifest: object) -> None: + data = json.dumps(manifest) + (root / "manifest.json").write_text(data) + (root / "manifest.js").write_text("window.FLYOVER_MANIFEST = " + data + ";\n") + + +def create_flyover_artifact(root: Path) -> Path: + (root / "assets").mkdir(parents=True) + image = root / "images/screen-0001/variant-0001/phone-light.png" + image.parent.mkdir(parents=True) + image.write_bytes(b"PNG") + (root / ".flyover-generated").write_text("schemaVersion=1\n") + (root / "index.html").write_text("") + (root / "assets/app.js").write_text("") + (root / "assets/styles.css").write_text("") + write_manifest(root, flyover_manifest_fixture()) + return root + + +def flyover_manifest_fixture() -> dict[str, Any]: + image_path = "images/screen-0001/variant-0001/phone-light.png" + screen_frame = {"x": 50, "y": 50, "width": 300, "height": 650} + return { + "schemaVersion": 1, + "application": {"id": "where", "title": "Where"}, + "build": { + "commit": "abc123", + "dirty": False, + "generatedAt": "2026-09-07T12:00:00Z", + "xcodeVersion": "Xcode 27.0", + "simulatorDevice": "iPhone 17", + "simulatorOS": "27.0", + }, + "profiles": [ + { + "id": "phone-light", + "title": "Phone Light", + "device": "phone", + "orientation": "portrait", + "colorScheme": "light", + "dynamicType": "large", + "contrast": "standard", + "layoutDirection": "left-to-right", + "legibilityWeight": "regular", + "snapshotType": "standard", + } + ], + "canvas": { + "size": {"width": 500, "height": 700}, + "initialFitSize": {"width": 500, "height": 700}, + "groupFrames": [ + { + "id": "group", + "frame": {"x": 0, "y": 0, "width": 500, "height": 700}, + } + ], + "depthBandFrames": [ + { + "groupID": "group", + "kind": "route", + "depth": 0, + "frame": {"x": 20, "y": 20, "width": 460, "height": 660}, + } + ], + "screenFrames": [{"id": "screen", "frame": screen_frame}], + "connectors": [], + }, + "groups": [ + { + "id": "group", + "title": "Group", + "order": 0, + "rootScreenID": "screen", + "screenIDs": ["screen"], + } + ], + "screens": [ + { + "id": "screen", + "title": "Screen", + "groupID": "group", + "groupOrder": 0, + "screenOrder": 0, + "viewport": {"kind": "device"}, + "navigationContainer": "stack", + "frame": screen_frame, + "variants": [ + { + "id": "default", + "title": "Default", + "captureExtent": "viewport", + "imagesByProfile": {"phone-light": image_path}, + } + ], + "incomingRouteIDs": [], + "outgoingRouteIDs": [], + } + ], + "routes": [], + "images": [ + { + "screenID": "screen", + "variantID": "default", + "profileID": "phone-light", + "relativePath": image_path, + "pointWidth": 402, + "pointHeight": 874, + "pixelWidth": 1206, + "pixelHeight": 2622, + "scale": 3, + "captureExtent": "viewport", + } + ], + } diff --git a/Tools/Tests/test_flyover_manifest.py b/Tools/Tests/test_flyover_manifest.py new file mode 100644 index 000000000..e2a13f78e --- /dev/null +++ b/Tools/Tests/test_flyover_manifest.py @@ -0,0 +1,242 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from Tools.Tests.flyover_test_support import ( + create_flyover_artifact, + write_manifest, + ) +except ModuleNotFoundError as error: + if error.name != "Tools": + raise + from flyover_test_support import create_flyover_artifact, write_manifest + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "flyover_manifest.py" +SPEC = importlib.util.spec_from_file_location("flyover_manifest", MODULE_PATH) +flyover_manifest = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +sys.modules[SPEC.name] = flyover_manifest +SPEC.loader.exec_module(flyover_manifest) + + +class FlyoverManifestTests(unittest.TestCase): + def test_validates_generated_artifact_and_builds_allowlist(self): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + + artifact = flyover_manifest.validate_artifact(root) + + self.assertEqual(artifact.root, root) + self.assertEqual( + artifact.allowed_paths, + frozenset( + { + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", + "images/screen-0001/variant-0001/phone-light.png", + } + ), + ) + + def test_rejects_invalid_marker_and_symbolic_links(self): + with tempfile.TemporaryDirectory() as temporary: + temporary_root = Path(temporary) + root = create_flyover_artifact(temporary_root / "atlas") + (root / ".flyover-generated").write_text("schemaVersion=2\n") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "generated marker is unsupported", + ): + flyover_manifest.validate_artifact(root) + + (root / ".flyover-generated").write_bytes(b"\xff") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "could not read", + ): + flyover_manifest.validate_artifact(root) + + (root / ".flyover-generated").write_text("schemaVersion=1\n") + (root / "leak").symlink_to(temporary_root / "outside") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "contains a symbolic link", + ): + flyover_manifest.validate_artifact(root) + + def test_rejects_unsafe_missing_duplicate_and_extra_images(self): + scenarios = ( + ("../outside.png", "unsafe image path"), + ("/outside.png", "unsafe image path"), + ("images/missing.png", "manifest image is missing"), + ) + for relative_path, message in scenarios: + with self.subTest(relative_path=relative_path): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["images"][0]["relativePath"] = relative_path + if relative_path == "images/missing.png": + manifest["screens"][0]["variants"][0]["imagesByProfile"][ + "phone-light" + ] = relative_path + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + message, + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["images"].append(dict(manifest["images"][0])) + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "duplicate screen, variant, and profile record", + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + (root / "images/extra.png").write_bytes(b"PNG") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "image files do not match", + ): + flyover_manifest.validate_artifact(root) + + def test_rejects_non_object_boolean_schema_and_mismatched_script(self): + scenarios = ( + ([], "not a JSON object"), + ({"schemaVersion": True, "images": []}, "schemaVersion 1"), + ) + for manifest, message in scenarios: + with self.subTest(manifest=manifest): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + message, + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + (root / "manifest.js").write_text("window.FLYOVER_MANIFEST = {};\n") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "manifest.js does not match manifest.json", + ): + flyover_manifest.validate_artifact(root) + + def test_rejects_incomplete_and_internally_inconsistent_manifests(self): + scenarios = ( + ( + lambda manifest: manifest.pop("application"), + "manifest.application is not an object", + ), + ( + lambda manifest: manifest["build"].pop("generatedAt"), + "manifest.build is missing generatedAt", + ), + ( + lambda manifest: manifest["canvas"].pop("screenFrames"), + "manifest.canvas is missing screenFrames", + ), + ( + lambda manifest: manifest["groups"][0].update( + { + "rootScreenID": "missing-screen", + "screenIDs": ["missing-screen"], + } + ), + "references an unknown screen", + ), + ( + lambda manifest: manifest["images"][0].update( + {"profileID": "missing-profile"} + ), + "references an unknown profile", + ), + ( + lambda manifest: manifest.update({"images": []}), + "manifest.images is empty", + ), + ) + for mutate, message in scenarios: + with self.subTest(message=message): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest = json.loads((root / "manifest.json").read_text()) + mutate(manifest) + write_manifest(root, manifest) + + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + message, + ): + flyover_manifest.validate_artifact(root) + + def test_accepts_optional_complete_thumbnail_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + thumbnail_relative = ( + "images/screen-0001/variant-0001/phone-light-thumbnail.png" + ) + thumbnail = root / thumbnail_relative + thumbnail.write_bytes(b"THUMBNAIL") + manifest = json.loads((root / "manifest.json").read_text()) + manifest["images"][0].update( + { + "thumbnailRelativePath": thumbnail_relative, + "thumbnailPixelWidth": 300, + "thumbnailPixelHeight": 650, + } + ) + write_manifest(root, manifest) + + artifact = flyover_manifest.validate_artifact(root) + + self.assertIn(thumbnail_relative, artifact.allowed_paths) + + def test_rejects_incomplete_or_undeclared_thumbnail_assets(self): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest = json.loads((root / "manifest.json").read_text()) + manifest["images"][0]["thumbnailRelativePath"] = ( + "images/screen-0001/variant-0001/phone-light-thumbnail.png" + ) + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "incomplete thumbnail metadata", + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + (root / "images/screen-0001/variant-0001/extra-thumbnail.png").write_bytes( + b"THUMBNAIL" + ) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "image files do not match", + ): + flyover_manifest.validate_artifact(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/Tests/test_flyover_preview.py b/Tools/Tests/test_flyover_preview.py new file mode 100644 index 000000000..d258f6c96 --- /dev/null +++ b/Tools/Tests/test_flyover_preview.py @@ -0,0 +1,268 @@ +import importlib.util +import io +import signal +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +try: + from Tools.Tests.flyover_test_support import create_flyover_artifact +except ModuleNotFoundError as error: + if error.name != "Tools": + raise + from flyover_test_support import create_flyover_artifact + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "flyover_preview.py" +SPEC = importlib.util.spec_from_file_location("flyover_preview", MODULE_PATH) +flyover_preview = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +sys.modules[SPEC.name] = flyover_preview +SPEC.loader.exec_module(flyover_preview) + + +class FlyoverPreviewTests(unittest.TestCase): + fixture = staticmethod(create_flyover_artifact) + + def test_opening_an_allowlisted_file_does_not_follow_a_replacement_symlink(self): + with tempfile.TemporaryDirectory() as temporary: + temporary_root = Path(temporary) + root = self.fixture(temporary_root / "atlas") + artifact = flyover_preview.validate_artifact(root) + image = root / "images/screen-0001/variant-0001/phone-light.png" + outside = temporary_root / "outside" + outside.write_bytes(b"SECRET") + + with flyover_preview._open_artifact_root(artifact) as descriptor: + image.unlink() + image.symlink_to(outside) + with self.assertRaises(OSError): + flyover_preview._open_file_beneath( + descriptor, + "images/screen-0001/variant-0001/phone-light.png", + ) + + def test_server_root_descriptor_stays_on_the_validated_directory(self): + with tempfile.TemporaryDirectory() as temporary: + temporary_root = Path(temporary) + root = self.fixture(temporary_root / "atlas") + artifact = flyover_preview.validate_artifact(root) + moved = temporary_root / "validated-atlas" + + with flyover_preview._open_artifact_root(artifact) as descriptor: + root.rename(moved) + root.mkdir() + (root / "manifest.json").write_bytes(b"SECRET") + with flyover_preview._open_file_beneath( + descriptor, + "manifest.json", + ) as manifest_file: + self.assertIn(b'"schemaVersion": 1', manifest_file.read()) + + def test_filters_and_sorts_usable_ipv4_addresses(self): + self.assertEqual( + flyover_preview.usable_ipv4_addresses( + ( + "192.168.1.20", + "127.0.0.1", + "0.0.0.0", + "224.0.0.1", + "169.254.2.3", + "10.0.0.7", + "192.168.1.20", + "::1", + "invalid", + ) + ), + ("10.0.0.7", "169.254.2.3", "192.168.1.20"), + ) + + def test_serves_loopback_on_an_automatic_port(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + servers = [] + + class FakeServer: + def __init__(self, address, handler): + self.address = address + self.handler = handler + self.server_address = (address[0], 53142) + self.did_serve = False + servers.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + self.did_serve = True + + output = io.StringIO() + flyover_preview.serve( + root, + lan=False, + port=0, + output=output, + server_factory=FakeServer, + ) + + self.assertEqual(servers[0].address, ("127.0.0.1", 0)) + self.assertTrue(servers[0].did_serve) + self.assertEqual( + output.getvalue().splitlines(), + [ + f"Flyover preview: {root}", + "Local: http://127.0.0.1:53142/", + "Press Ctrl-C to stop.", + ], + ) + + def test_ctrl_c_stops_preview_and_restores_signal_handler(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + shutdown_called = threading.Event() + servers = [] + + class InterruptingOutput(io.StringIO): + def __init__(self): + super().__init__() + self.did_interrupt = False + + def flush(self): + super().flush() + if not self.did_interrupt: + self.did_interrupt = True + signal.raise_signal(signal.SIGINT) + + class FakeServer: + server_address = ("127.0.0.1", 53142) + + def __init__(self, *_): + self.serving_thread = None + self.shutdown_thread = None + servers.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + self.serving_thread = threading.current_thread() + if not shutdown_called.wait(timeout=5): + raise AssertionError("preview shutdown was not requested") + + def shutdown(self): + self.shutdown_thread = threading.current_thread() + shutdown_called.set() + + previous_handler = signal.getsignal(signal.SIGINT) + + flyover_preview.serve( + root, + lan=False, + port=0, + output=InterruptingOutput(), + server_factory=FakeServer, + ) + + self.assertTrue(shutdown_called.is_set()) + self.assertIsNot(servers[0].serving_thread, servers[0].shutdown_thread) + self.assertEqual(signal.getsignal(signal.SIGINT), previous_handler) + + def test_server_failure_is_rethrown_and_restores_signal_handler(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + + class FakeServer: + server_address = ("127.0.0.1", 53142) + + def __init__(self, *_): + pass + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + raise RuntimeError("server failed") + + previous_handler = signal.getsignal(signal.SIGINT) + + with self.assertRaisesRegex(RuntimeError, "server failed"): + flyover_preview.serve( + root, + lan=False, + port=0, + output=io.StringIO(), + server_factory=FakeServer, + ) + + self.assertEqual(signal.getsignal(signal.SIGINT), previous_handler) + + def test_lan_preview_prints_each_network_url_and_warning(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + addresses = [] + + class FakeServer: + server_address = ("0.0.0.0", 8080) + + def __init__(self, address, _): + addresses.append(address) + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + return None + + output = io.StringIO() + flyover_preview.serve( + root, + lan=True, + port=8080, + output=output, + server_factory=FakeServer, + address_provider=lambda: ("10.0.0.7", "192.168.1.20"), + ) + + self.assertEqual(addresses, [("0.0.0.0", 8080)]) + self.assertIn("Network: http://10.0.0.7:8080/", output.getvalue()) + self.assertIn("Network: http://192.168.1.20:8080/", output.getvalue()) + self.assertIn( + "Warning: LAN preview has no authentication or TLS.", + output.getvalue(), + ) + + def test_reports_bind_errors_without_starting_the_server(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + + def failed_server(*_): + raise OSError("address already in use") + + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "could not start the preview server.*address already in use", + ): + flyover_preview.serve( + root, + lan=False, + port=4173, + server_factory=failed_server, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/Tests/test_test_runner.py b/Tools/Tests/test_test_runner.py index e81e16f45..b0b4b61aa 100644 --- a/Tools/Tests/test_test_runner.py +++ b/Tools/Tests/test_test_runner.py @@ -89,6 +89,27 @@ def test_progress_uses_images_without_a_cached_test_total(self): self.assertIn("1 images", output.getvalue()) + def test_progress_passes_through_flyover_export_status(self): + with tempfile.TemporaryDirectory() as directory: + output = io.StringIO() + reporter = ProgressReporter( + heartbeat=15, + status_path=None, + counts_path=Path(directory) / "counts.json", + scheme="Snapshots", + is_terminal=False, + count_images=False, + output=output, + clock=Clock(), + ) + + reporter.consume("FLYOVER_EXPORT 37/110 Locations / Empty / phone-light") + + self.assertIn( + "FLYOVER_EXPORT 37/110 Locations / Empty / phone-light", + output.getvalue(), + ) + def test_progress_keeps_cached_test_count_labeled_as_tests(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/Tools/Tests/test_xcode_command_contracts.py b/Tools/Tests/test_xcode_command_contracts.py index c721aa3f6..c7ef7096f 100644 --- a/Tools/Tests/test_xcode_command_contracts.py +++ b/Tools/Tests/test_xcode_command_contracts.py @@ -253,6 +253,20 @@ def test_unit_run_does_not_require_the_snapshot_xcode_build(self): self.assertEqual(0, result.returncode, result.stdout + result.stderr) self.assertNotIn("xcodebuild -version", fixture.command_log()) + def test_unit_run_does_not_require_the_flyover_command_fixture(self): + fixture = self.fixture() + + result = fixture.run( + "test", + "--skip-architecture", + "--no-generate", + "--no-build", + "CoreTests", + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn("Testing flyover command", result.stdout) + def test_test_preserves_xcode_failure_through_the_progress_pipeline(self): fixture = self.fixture() diff --git a/Tools/flyover_manifest.py b/Tools/flyover_manifest.py new file mode 100644 index 000000000..1ea253813 --- /dev/null +++ b/Tools/flyover_manifest.py @@ -0,0 +1,838 @@ +"""Schema and filesystem validation for generated Flyover atlases.""" + +from __future__ import annotations + +import json +import math +import os +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + + +MARKER_CONTENT = "schemaVersion=1" +REQUIRED_FILES = ( + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", +) + + +class FlyoverArtifactError(ValueError): + """A generated atlas is incomplete or unsafe to serve.""" + + +@dataclass(frozen=True) +class FlyoverArtifact: + root: Path + allowed_paths: frozenset[str] + root_device: int + root_inode: int + + +def validate_artifact( + directory: Path, + *, + require_marker: bool = True, +) -> FlyoverArtifact: + """Validate a generated atlas and return its HTTP allowlist.""" + root = directory + try: + root_status = os.lstat(root) + except FileNotFoundError: + raise FlyoverArtifactError( + f"no generated atlas exists at {root}. Run ./flyover export first." + ) from None + except OSError as error: + raise FlyoverArtifactError(f"could not inspect the atlas path {root}: {error}") from error + if stat.S_ISLNK(root_status.st_mode): + raise FlyoverArtifactError(f"the atlas directory is a symbolic link: {root}") + if not stat.S_ISDIR(root_status.st_mode): + raise FlyoverArtifactError(f"the atlas path is not a directory: {root}") + + symbolic_link = next((path for path in root.rglob("*") if path.is_symlink()), None) + if symbolic_link is not None: + relative = symbolic_link.relative_to(root) + raise FlyoverArtifactError(f"the atlas contains a symbolic link: {relative}") + + marker = root / ".flyover-generated" + if require_marker: + if not marker.is_file(): + raise FlyoverArtifactError( + f"the directory is not a generated Flyover atlas: {root}" + ) + try: + marker_content = marker.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError) as error: + raise FlyoverArtifactError(f"could not read {marker}: {error}") from error + if marker_content != MARKER_CONTENT: + raise FlyoverArtifactError( + f"the generated marker is unsupported: {marker_content or 'empty'}" + ) + + for relative in REQUIRED_FILES: + path = root / relative + if not path.is_file(): + raise FlyoverArtifactError(f"the generated atlas is missing {relative}") + + manifest_path = root / "manifest.json" + try: + manifest_data = manifest_path.read_bytes() + manifest = json.loads(manifest_data) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise FlyoverArtifactError(f"could not read manifest.json: {error}") from error + if not isinstance(manifest, dict): + raise FlyoverArtifactError("manifest.json is not a JSON object") + if ( + type(manifest.get("schemaVersion")) is not int + or manifest["schemaVersion"] != 1 + ): + raise FlyoverArtifactError("manifest.json does not use schemaVersion 1") + _validate_manifest(manifest) + + manifest_script_path = root / "manifest.js" + try: + manifest_script = manifest_script_path.read_bytes() + except OSError as error: + raise FlyoverArtifactError(f"could not read manifest.js: {error}") from error + expected_script = b"window.FLYOVER_MANIFEST = " + manifest_data + b";\n" + if manifest_script != expected_script: + raise FlyoverArtifactError("manifest.js does not match manifest.json") + + images = manifest.get("images") + if not isinstance(images, list): + raise FlyoverArtifactError("manifest.json has no image list") + + asset_paths: list[str] = [] + for image in images: + if not isinstance(image, dict): + raise FlyoverArtifactError("manifest.json contains an invalid image record") + relative_value = image.get("relativePath") + if not isinstance(relative_value, str): + raise FlyoverArtifactError("manifest.json contains an invalid image path") + relative = _safe_image_path(relative_value) + path = root.joinpath(*relative.parts) + if not path.is_file(): + raise FlyoverArtifactError(f"the manifest image is missing: {relative}") + asset_paths.append(relative.as_posix()) + + if "thumbnailRelativePath" in image: + thumbnail_value = image["thumbnailRelativePath"] + assert isinstance(thumbnail_value, str) + thumbnail = _safe_image_path(thumbnail_value) + thumbnail_path = root.joinpath(*thumbnail.parts) + if not thumbnail_path.is_file(): + raise FlyoverArtifactError( + f"the manifest thumbnail is missing: {thumbnail}" + ) + asset_paths.append(thumbnail.as_posix()) + + if len(set(asset_paths)) != len(asset_paths): + raise FlyoverArtifactError("manifest.json contains a duplicate image or thumbnail path") + + images_directory = root / "images" + if images_directory.is_dir(): + actual_paths = { + path.relative_to(root).as_posix() + for path in images_directory.rglob("*.png") + if path.is_file() + } + else: + actual_paths = set() + declared_paths = set(asset_paths) + if actual_paths != declared_paths: + raise FlyoverArtifactError( + "the image files do not match the manifest " + f"({len(declared_paths)} declared, {len(actual_paths)} found)" + ) + + return FlyoverArtifact( + root=root, + allowed_paths=frozenset((*REQUIRED_FILES, *asset_paths)), + root_device=root_status.st_dev, + root_inode=root_status.st_ino, + ) + + +def _validate_manifest(manifest: dict[str, object]) -> None: + """Validate all schema-1 data that the browser reads.""" + application = _required_object( + manifest.get("application"), + "manifest.application", + ("id", "title"), + ) + _string(application["id"], "manifest.application.id", nonempty=True) + _string(application["title"], "manifest.application.title", nonempty=True) + + build = _required_object( + manifest.get("build"), + "manifest.build", + ( + "commit", + "dirty", + "generatedAt", + "xcodeVersion", + "simulatorDevice", + "simulatorOS", + ), + ) + for field in ( + "commit", + "generatedAt", + "xcodeVersion", + "simulatorDevice", + "simulatorOS", + ): + _string(build[field], f"manifest.build.{field}", nonempty=True) + if type(build["dirty"]) is not bool: + raise FlyoverArtifactError("manifest.build.dirty is not a Boolean") + branch = build.get("branch") + if branch is not None: + _string(branch, "manifest.build.branch") + + profiles = _required_list(manifest.get("profiles"), "manifest.profiles", nonempty=True) + profile_by_id = _identified_records( + profiles, + "manifest.profiles", + ( + "id", + "title", + "device", + "orientation", + "colorScheme", + "dynamicType", + "contrast", + "layoutDirection", + "legibilityWeight", + "snapshotType", + ), + ) + profile_values = { + "device": {"phone", "tablet"}, + "orientation": {"portrait", "landscape"}, + "colorScheme": {"light", "dark"}, + "dynamicType": {"small", "large", "xxxl", "accessibility3"}, + "contrast": {"standard", "increased"}, + "layoutDirection": {"left-to-right", "right-to-left"}, + "legibilityWeight": {"regular", "bold"}, + "snapshotType": {"standard", "accessibility"}, + } + for index, profile in enumerate(profiles): + assert isinstance(profile, dict) + _string(profile["title"], f"manifest.profiles[{index}].title", nonempty=True) + for field, accepted in profile_values.items(): + value = _string(profile[field], f"manifest.profiles[{index}].{field}") + if value not in accepted: + raise FlyoverArtifactError( + f"manifest.profiles[{index}].{field} has an unsupported value: {value}" + ) + + groups = _required_list(manifest.get("groups"), "manifest.groups", nonempty=True) + group_by_id = _identified_records( + groups, + "manifest.groups", + ("id", "title", "order", "rootScreenID", "screenIDs"), + ) + for index, group in enumerate(groups): + assert isinstance(group, dict) + _string(group["title"], f"manifest.groups[{index}].title", nonempty=True) + if _integer(group["order"], f"manifest.groups[{index}].order") != index: + raise FlyoverArtifactError("manifest.groups are not in stable order") + _string( + group["rootScreenID"], + f"manifest.groups[{index}].rootScreenID", + nonempty=True, + ) + screen_ids = _string_list( + group["screenIDs"], + f"manifest.groups[{index}].screenIDs", + nonempty=True, + ) + if len(set(screen_ids)) != len(screen_ids): + raise FlyoverArtifactError( + f"manifest.groups[{index}].screenIDs contains a duplicate identifier" + ) + + screens = _required_list(manifest.get("screens"), "manifest.screens", nonempty=True) + screen_by_id = _identified_records( + screens, + "manifest.screens", + ( + "id", + "title", + "groupID", + "groupOrder", + "screenOrder", + "viewport", + "navigationContainer", + "frame", + "variants", + "incomingRouteIDs", + "outgoingRouteIDs", + ), + ) + variant_by_key: dict[tuple[str, str], dict[str, object]] = {} + expected_screen_ids: list[str] = [] + for group_index, group in enumerate(groups): + assert isinstance(group, dict) + group_id = str(group["id"]) + group_screen_ids = _string_list( + group["screenIDs"], + f"manifest.groups[{group_index}].screenIDs", + nonempty=True, + ) + root_screen_id = str(group["rootScreenID"]) + if root_screen_id not in group_screen_ids: + raise FlyoverArtifactError( + f"manifest.groups[{group_index}].rootScreenID is not in its screenIDs" + ) + expected_screen_ids.extend(group_screen_ids) + for screen_index, screen_id in enumerate(group_screen_ids): + screen = screen_by_id.get(screen_id) + if screen is None: + raise FlyoverArtifactError( + f"manifest.groups[{group_index}].screenIDs references an unknown screen: " + f"{screen_id}" + ) + if screen["groupID"] != group_id: + raise FlyoverArtifactError( + f"manifest.screens[{screen_id}].groupID does not match its group" + ) + if screen["groupOrder"] != group_index or screen["screenOrder"] != screen_index: + raise FlyoverArtifactError( + f"manifest.screens[{screen_id}] has inconsistent ordering" + ) + if len(set(expected_screen_ids)) != len(expected_screen_ids): + raise FlyoverArtifactError("manifest group screen lists contain a duplicate screen") + if expected_screen_ids != list(screen_by_id): + raise FlyoverArtifactError("manifest group screen lists do not match manifest.screens") + + for index, screen in enumerate(screens): + assert isinstance(screen, dict) + screen_id = str(screen["id"]) + _string(screen["title"], f"manifest.screens[{index}].title", nonempty=True) + group_id = _string( + screen["groupID"], + f"manifest.screens[{index}].groupID", + nonempty=True, + ) + if group_id not in group_by_id: + raise FlyoverArtifactError( + f"manifest.screens[{index}].groupID references an unknown group: {group_id}" + ) + _integer(screen["groupOrder"], f"manifest.screens[{index}].groupOrder") + _integer(screen["screenOrder"], f"manifest.screens[{index}].screenOrder") + _validate_viewport(screen["viewport"], f"manifest.screens[{index}].viewport") + navigation = _string( + screen["navigationContainer"], + f"manifest.screens[{index}].navigationContainer", + ) + if navigation not in {"stack", "none"}: + raise FlyoverArtifactError( + f"manifest.screens[{index}].navigationContainer has an unsupported value: " + f"{navigation}" + ) + _validate_rect(screen["frame"], f"manifest.screens[{index}].frame") + _string_list( + screen["incomingRouteIDs"], + f"manifest.screens[{index}].incomingRouteIDs", + ) + _string_list( + screen["outgoingRouteIDs"], + f"manifest.screens[{index}].outgoingRouteIDs", + ) + + variants = _required_list( + screen["variants"], + f"manifest.screens[{index}].variants", + nonempty=True, + ) + local_ids: set[str] = set() + for variant_index, value in enumerate(variants): + variant = _required_object( + value, + f"manifest.screens[{index}].variants[{variant_index}]", + ("id", "title", "captureExtent", "imagesByProfile"), + ) + variant_id = _string( + variant["id"], + f"manifest.screens[{index}].variants[{variant_index}].id", + nonempty=True, + ) + if variant_id in local_ids: + raise FlyoverArtifactError( + f"manifest.screens[{index}].variants contains a duplicate identifier: " + f"{variant_id}" + ) + local_ids.add(variant_id) + _string( + variant["title"], + f"manifest.screens[{index}].variants[{variant_index}].title", + nonempty=True, + ) + extent = _capture_extent( + variant["captureExtent"], + f"manifest.screens[{index}].variants[{variant_index}].captureExtent", + ) + images_by_profile = _required_object( + variant["imagesByProfile"], + f"manifest.screens[{index}].variants[{variant_index}].imagesByProfile", + tuple(profile_by_id), + ) + if set(images_by_profile) != set(profile_by_id): + raise FlyoverArtifactError( + f"manifest.screens[{index}].variants[{variant_index}].imagesByProfile " + "does not match manifest.profiles" + ) + for profile_id, relative_path in images_by_profile.items(): + _safe_image_path( + _string( + relative_path, + f"manifest.screens[{index}].variants[{variant_index}]" + f".imagesByProfile[{profile_id}]", + nonempty=True, + ) + ) + variant_by_key[(screen_id, variant_id)] = variant + + routes = _required_list(manifest.get("routes"), "manifest.routes") + route_by_id = _identified_records( + routes, + "manifest.routes", + ("id", "sourceScreenID", "destinationScreenID", "kind", "geometry"), + ) + for index, route in enumerate(routes): + assert isinstance(route, dict) + for field in ("sourceScreenID", "destinationScreenID"): + screen_id = _string( + route[field], + f"manifest.routes[{index}].{field}", + nonempty=True, + ) + if screen_id not in screen_by_id: + raise FlyoverArtifactError( + f"manifest.routes[{index}].{field} references an unknown screen: " + f"{screen_id}" + ) + kind = _string(route["kind"], f"manifest.routes[{index}].kind") + if kind not in {"push", "modal"}: + raise FlyoverArtifactError( + f"manifest.routes[{index}].kind has an unsupported value: {kind}" + ) + label = route.get("label") + if label is not None: + _string(label, f"manifest.routes[{index}].label") + _validate_geometry(route["geometry"], f"manifest.routes[{index}].geometry") + + for index, screen in enumerate(screens): + assert isinstance(screen, dict) + screen_id = str(screen["id"]) + expected_incoming = [ + str(route["id"]) + for route in routes + if isinstance(route, dict) and route["destinationScreenID"] == screen_id + ] + expected_outgoing = [ + str(route["id"]) + for route in routes + if isinstance(route, dict) and route["sourceScreenID"] == screen_id + ] + if screen["incomingRouteIDs"] != expected_incoming: + raise FlyoverArtifactError( + f"manifest.screens[{index}].incomingRouteIDs does not match manifest.routes" + ) + if screen["outgoingRouteIDs"] != expected_outgoing: + raise FlyoverArtifactError( + f"manifest.screens[{index}].outgoingRouteIDs does not match manifest.routes" + ) + + _validate_canvas(manifest.get("canvas"), group_by_id, screen_by_id, route_by_id) + _validate_images(manifest.get("images"), profile_by_id, screen_by_id, variant_by_key) + + +def _validate_canvas( + value: object, + group_by_id: dict[str, dict[str, object]], + screen_by_id: dict[str, dict[str, object]], + route_by_id: dict[str, dict[str, object]], +) -> None: + canvas = _required_object( + value, + "manifest.canvas", + ( + "size", + "initialFitSize", + "groupFrames", + "depthBandFrames", + "screenFrames", + "connectors", + ), + ) + _validate_size(canvas["size"], "manifest.canvas.size") + _validate_size(canvas["initialFitSize"], "manifest.canvas.initialFitSize") + group_frames = _identified_records( + _required_list(canvas["groupFrames"], "manifest.canvas.groupFrames"), + "manifest.canvas.groupFrames", + ("id", "frame"), + ) + screen_frames = _identified_records( + _required_list(canvas["screenFrames"], "manifest.canvas.screenFrames"), + "manifest.canvas.screenFrames", + ("id", "frame"), + ) + if set(group_frames) != set(group_by_id): + raise FlyoverArtifactError("manifest.canvas.groupFrames does not match manifest.groups") + if set(screen_frames) != set(screen_by_id): + raise FlyoverArtifactError("manifest.canvas.screenFrames does not match manifest.screens") + for identifier, record in group_frames.items(): + _validate_rect(record["frame"], f"manifest.canvas.groupFrames[{identifier}].frame") + for identifier, record in screen_frames.items(): + frame = _validate_rect( + record["frame"], + f"manifest.canvas.screenFrames[{identifier}].frame", + ) + if frame != screen_by_id[identifier]["frame"]: + raise FlyoverArtifactError( + f"manifest.canvas.screenFrames[{identifier}] does not match its screen frame" + ) + + depth_bands = _required_list( + canvas["depthBandFrames"], + "manifest.canvas.depthBandFrames", + ) + for index, value in enumerate(depth_bands): + band = _required_object( + value, + f"manifest.canvas.depthBandFrames[{index}]", + ("groupID", "kind", "frame"), + ) + group_id = _string( + band["groupID"], + f"manifest.canvas.depthBandFrames[{index}].groupID", + nonempty=True, + ) + if group_id not in group_by_id: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].groupID references an unknown group" + ) + kind = _string(band["kind"], f"manifest.canvas.depthBandFrames[{index}].kind") + depth = band.get("depth") + if kind == "route": + if _integer(depth, f"manifest.canvas.depthBandFrames[{index}].depth") < 0: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].depth is negative" + ) + elif kind == "unlinked": + if depth is not None: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].depth must be null" + ) + else: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].kind has an unsupported value: " + f"{kind}" + ) + _validate_rect(band["frame"], f"manifest.canvas.depthBandFrames[{index}].frame") + + connectors = _required_list(canvas["connectors"], "manifest.canvas.connectors") + connector_by_route: dict[str, dict[str, object]] = {} + for index, value in enumerate(connectors): + connector = _required_object( + value, + f"manifest.canvas.connectors[{index}]", + ("routeID", "geometry"), + ) + route_id = _string( + connector["routeID"], + f"manifest.canvas.connectors[{index}].routeID", + nonempty=True, + ) + if route_id in connector_by_route: + raise FlyoverArtifactError( + f"manifest.canvas.connectors contains a duplicate routeID: {route_id}" + ) + connector_by_route[route_id] = connector + _validate_geometry( + connector["geometry"], + f"manifest.canvas.connectors[{index}].geometry", + ) + if set(connector_by_route) != set(route_by_id): + raise FlyoverArtifactError("manifest.canvas.connectors does not match manifest.routes") + for route_id, connector in connector_by_route.items(): + if connector["geometry"] != route_by_id[route_id]["geometry"]: + raise FlyoverArtifactError( + f"manifest.canvas.connectors[{route_id}] does not match its route geometry" + ) + + +def _validate_images( + value: object, + profile_by_id: dict[str, dict[str, object]], + screen_by_id: dict[str, dict[str, object]], + variant_by_key: dict[tuple[str, str], dict[str, object]], +) -> None: + images = _required_list(value, "manifest.images", nonempty=True) + image_by_key: dict[tuple[str, str, str], dict[str, object]] = {} + paths: set[str] = set() + for index, value in enumerate(images): + image = _required_object( + value, + f"manifest.images[{index}]", + ( + "screenID", + "variantID", + "profileID", + "relativePath", + "pointWidth", + "pointHeight", + "pixelWidth", + "pixelHeight", + "scale", + "captureExtent", + ), + ) + thumbnail_fields = ( + "thumbnailRelativePath", + "thumbnailPixelWidth", + "thumbnailPixelHeight", + ) + present_thumbnail_fields = [field for field in thumbnail_fields if field in image] + if present_thumbnail_fields and len(present_thumbnail_fields) != len(thumbnail_fields): + raise FlyoverArtifactError( + f"manifest.images[{index}] has incomplete thumbnail metadata" + ) + screen_id = _string(image["screenID"], f"manifest.images[{index}].screenID", nonempty=True) + variant_id = _string( + image["variantID"], + f"manifest.images[{index}].variantID", + nonempty=True, + ) + profile_id = _string( + image["profileID"], + f"manifest.images[{index}].profileID", + nonempty=True, + ) + if screen_id not in screen_by_id: + raise FlyoverArtifactError( + f"manifest.images[{index}].screenID references an unknown screen: {screen_id}" + ) + variant = variant_by_key.get((screen_id, variant_id)) + if variant is None: + raise FlyoverArtifactError( + f"manifest.images[{index}].variantID references an unknown variant: {variant_id}" + ) + if profile_id not in profile_by_id: + raise FlyoverArtifactError( + f"manifest.images[{index}].profileID references an unknown profile: {profile_id}" + ) + key = (screen_id, variant_id, profile_id) + if key in image_by_key: + raise FlyoverArtifactError( + "manifest.images contains a duplicate screen, variant, and profile record" + ) + image_by_key[key] = image + relative_path = _safe_image_path( + _string( + image["relativePath"], + f"manifest.images[{index}].relativePath", + nonempty=True, + ) + ).as_posix() + if relative_path in paths: + raise FlyoverArtifactError("manifest.json contains a duplicate image path") + paths.add(relative_path) + if present_thumbnail_fields: + thumbnail_path = _safe_image_path( + _string( + image["thumbnailRelativePath"], + f"manifest.images[{index}].thumbnailRelativePath", + nonempty=True, + ) + ).as_posix() + if thumbnail_path in paths: + raise FlyoverArtifactError( + "manifest.json contains a duplicate image or thumbnail path" + ) + paths.add(thumbnail_path) + for field in ("thumbnailPixelWidth", "thumbnailPixelHeight"): + if _integer(image[field], f"manifest.images[{index}].{field}") <= 0: + raise FlyoverArtifactError( + f"manifest.images[{index}].{field} is not positive" + ) + expected_path = variant["imagesByProfile"][profile_id] + if relative_path != expected_path: + raise FlyoverArtifactError( + f"manifest.images[{index}].relativePath does not match imagesByProfile" + ) + extent = _capture_extent( + image["captureExtent"], + f"manifest.images[{index}].captureExtent", + ) + if extent != variant["captureExtent"]: + raise FlyoverArtifactError( + f"manifest.images[{index}].captureExtent does not match its variant" + ) + for field in ("pointWidth", "pointHeight", "scale"): + if _number(image[field], f"manifest.images[{index}].{field}") <= 0: + raise FlyoverArtifactError(f"manifest.images[{index}].{field} is not positive") + for field in ("pixelWidth", "pixelHeight"): + if _integer(image[field], f"manifest.images[{index}].{field}") <= 0: + raise FlyoverArtifactError(f"manifest.images[{index}].{field} is not positive") + + expected_keys = { + (screen_id, variant_id, profile_id) + for screen_id, variant_id in variant_by_key + for profile_id in profile_by_id + } + if set(image_by_key) != expected_keys: + raise FlyoverArtifactError( + "manifest.images does not contain exactly one image for each state and profile" + ) + + +def _required_object( + value: object, + path: str, + fields: tuple[str, ...], +) -> dict[str, object]: + if not isinstance(value, dict): + raise FlyoverArtifactError(f"{path} is not an object") + missing = next((field for field in fields if field not in value), None) + if missing is not None: + raise FlyoverArtifactError(f"{path} is missing {missing}") + return value + + +def _required_list(value: object, path: str, *, nonempty: bool = False) -> list[object]: + if not isinstance(value, list): + raise FlyoverArtifactError(f"{path} is not an array") + if nonempty and not value: + raise FlyoverArtifactError(f"{path} is empty") + return value + + +def _identified_records( + values: list[object], + path: str, + fields: tuple[str, ...], +) -> dict[str, dict[str, object]]: + records: dict[str, dict[str, object]] = {} + for index, value in enumerate(values): + record = _required_object(value, f"{path}[{index}]", fields) + identifier = _string(record["id"], f"{path}[{index}].id", nonempty=True) + if identifier in records: + raise FlyoverArtifactError(f"{path} contains a duplicate identifier: {identifier}") + records[identifier] = record + return records + + +def _string(value: object, path: str, *, nonempty: bool = False) -> str: + if not isinstance(value, str): + raise FlyoverArtifactError(f"{path} is not a string") + if nonempty and not value: + raise FlyoverArtifactError(f"{path} is empty") + return value + + +def _string_list(value: object, path: str, *, nonempty: bool = False) -> list[str]: + values = _required_list(value, path, nonempty=nonempty) + return [_string(item, f"{path}[{index}]", nonempty=True) for index, item in enumerate(values)] + + +def _integer(value: object, path: str) -> int: + if type(value) is not int: + raise FlyoverArtifactError(f"{path} is not an integer") + return value + + +def _number(value: object, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise FlyoverArtifactError(f"{path} is not a number") + result = float(value) + if not math.isfinite(result): + raise FlyoverArtifactError(f"{path} is not finite") + return result + + +def _validate_size(value: object, path: str) -> dict[str, object]: + size = _required_object(value, path, ("width", "height")) + for field in ("width", "height"): + if _number(size[field], f"{path}.{field}") <= 0: + raise FlyoverArtifactError(f"{path}.{field} is not positive") + return size + + +def _validate_rect(value: object, path: str) -> dict[str, object]: + rect = _required_object(value, path, ("x", "y", "width", "height")) + _number(rect["x"], f"{path}.x") + _number(rect["y"], f"{path}.y") + for field in ("width", "height"): + if _number(rect[field], f"{path}.{field}") <= 0: + raise FlyoverArtifactError(f"{path}.{field} is not positive") + return rect + + +def _validate_point(value: object, path: str) -> None: + point = _required_object(value, path, ("x", "y")) + _number(point["x"], f"{path}.x") + _number(point["y"], f"{path}.y") + + +def _validate_geometry(value: object, path: str) -> None: + geometry = _required_object( + value, + path, + ( + "start", + "end", + "firstControl", + "secondControl", + "firstArrowPoint", + "secondArrowPoint", + ), + ) + for field in ( + "start", + "end", + "firstControl", + "secondControl", + "firstArrowPoint", + "secondArrowPoint", + ): + _validate_point(geometry[field], f"{path}.{field}") + + +def _validate_viewport(value: object, path: str) -> None: + viewport = _required_object(value, path, ("kind",)) + kind = _string(viewport["kind"], f"{path}.kind") + fixed_size = viewport.get("fixedSize") + if kind == "device": + if fixed_size is not None: + raise FlyoverArtifactError(f"{path}.fixedSize must be null for a device viewport") + elif kind == "fixed": + _validate_size(fixed_size, f"{path}.fixedSize") + else: + raise FlyoverArtifactError(f"{path}.kind has an unsupported value: {kind}") + + +def _capture_extent(value: object, path: str) -> str: + extent = _string(value, path) + if extent not in {"viewport", "intrinsic", "fullContent", "fullContent2D"}: + raise FlyoverArtifactError(f"{path} has an unsupported value: {extent}") + return extent + + +def _safe_image_path(value: str) -> PurePosixPath: + relative = PurePosixPath(value) + if ( + not value + or "\\" in value + or relative.is_absolute() + or ".." in relative.parts + or relative.parts[:1] != ("images",) + or relative.suffix.lower() != ".png" + ): + raise FlyoverArtifactError(f"the manifest contains an unsafe image path: {value}") + return relative diff --git a/Tools/flyover_preview.py b/Tools/flyover_preview.py new file mode 100644 index 000000000..39972209e --- /dev/null +++ b/Tools/flyover_preview.py @@ -0,0 +1,312 @@ +"""Local HTTP serving for validated Flyover atlases.""" + +from __future__ import annotations + +import argparse +import ipaddress +import os +import signal +import socket +import stat +import sys +import threading +from contextlib import contextmanager +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path, PurePosixPath +from typing import BinaryIO, Callable, Iterable, Iterator, Optional, TextIO +from urllib.parse import unquote, urlsplit + +try: + from Tools.flyover_manifest import ( + FlyoverArtifact, + FlyoverArtifactError, + validate_artifact, + ) +except ModuleNotFoundError as error: + if error.name != "Tools": + raise + from flyover_manifest import ( + FlyoverArtifact, + FlyoverArtifactError, + validate_artifact, + ) + + +def usable_ipv4_addresses(candidates: Iterable[str]) -> tuple[str, ...]: + """Return deterministic non-loopback IPv4 addresses.""" + addresses: set[ipaddress.IPv4Address] = set() + for candidate in candidates: + try: + address = ipaddress.ip_address(candidate) + except ValueError: + continue + if not isinstance(address, ipaddress.IPv4Address): + continue + if address.is_unspecified or address.is_loopback or address.is_multicast: + continue + addresses.add(address) + return tuple(str(address) for address in sorted(addresses)) + + +def discover_network_addresses() -> tuple[str, ...]: + """Discover IPv4 addresses that can identify this host on a local network.""" + candidates: list[str] = [] + try: + candidates.extend( + item[4][0] + for item in socket.getaddrinfo( + socket.gethostname(), + None, + family=socket.AF_INET, + type=socket.SOCK_STREAM, + ) + ) + except OSError: + pass + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("192.0.2.1", 9)) + candidates.append(probe.getsockname()[0]) + except OSError: + pass + return usable_ipv4_addresses(candidates) + + +class FlyoverRequestHandler(SimpleHTTPRequestHandler): + """Serve only files declared by a validated Flyover artifact.""" + + def __init__( + self, + *args: object, + root_descriptor: int, + allowed_paths: frozenset[str], + **kwargs: object, + ) -> None: + self.root_descriptor = os.dup(root_descriptor) + self.allowed_paths = allowed_paths + try: + super().__init__(*args, directory="/", **kwargs) + finally: + os.close(self.root_descriptor) + + def send_head(self) -> Optional[BinaryIO]: + relative = self._allowed_relative_path() + if relative is None: + self.send_error(404) + return None + try: + file = _open_file_beneath(self.root_descriptor, relative) + except OSError: + self.send_error(404) + return None + + try: + status = os.fstat(file.fileno()) + self.send_response(200) + self.send_header("Content-type", self.guess_type(relative)) + self.send_header("Content-Length", str(status.st_size)) + self.send_header("Last-Modified", self.date_time_string(status.st_mtime)) + self.end_headers() + return file + except BaseException: + file.close() + raise + + def list_directory(self, path: str) -> None: + self.send_error(404) + return None + + def log_message(self, message: str, *args: object) -> None: + """Keep untrusted HTTP request data out of the terminal.""" + + def _allowed_relative_path(self) -> Optional[str]: + try: + target = urlsplit(self.path) + except ValueError: + return None + if target.scheme or target.netloc or target.fragment: + return None + raw_path = unquote(target.path) + if raw_path == "/": + return "index.html" if "index.html" in self.allowed_paths else None + if not raw_path.startswith("/") or raw_path.endswith("/"): + return None + segments = raw_path[1:].split("/") + if any(segment in ("", ".", "..") for segment in segments): + return None + relative = "/".join(segments) + return relative if relative in self.allowed_paths else None + + +def _open_file_beneath(root_descriptor: int, relative: str) -> BinaryIO: + """Open one allowlisted regular file without following symbolic links.""" + flags = os.O_RDONLY | os.O_NOFOLLOW + directory_flags = flags | os.O_DIRECTORY + current_descriptor = os.dup(root_descriptor) + file_descriptor: Optional[int] = None + try: + parts = PurePosixPath(relative).parts + for part in parts[:-1]: + next_descriptor = os.open( + part, + directory_flags, + dir_fd=current_descriptor, + ) + os.close(current_descriptor) + current_descriptor = next_descriptor + file_descriptor = os.open(parts[-1], flags, dir_fd=current_descriptor) + if not stat.S_ISREG(os.fstat(file_descriptor).st_mode): + raise OSError("the request target is not a regular file") + file = os.fdopen(file_descriptor, "rb") + file_descriptor = None + return file + finally: + if file_descriptor is not None: + os.close(file_descriptor) + os.close(current_descriptor) + + +@contextmanager +def _open_artifact_root(artifact: FlyoverArtifact) -> Iterator[int]: + """Pin the validated atlas directory for the server lifetime.""" + try: + descriptor = os.open( + artifact.root, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + except OSError as error: + raise FlyoverArtifactError( + f"could not open the validated atlas directory {artifact.root}: {error}" + ) from error + try: + status = os.fstat(descriptor) + if (status.st_dev, status.st_ino) != (artifact.root_device, artifact.root_inode): + raise FlyoverArtifactError("the atlas directory changed during server startup") + yield descriptor + finally: + os.close(descriptor) + + +ServerFactory = Callable[..., ThreadingHTTPServer] + + +@contextmanager +def _serve_until_interrupted(server: ThreadingHTTPServer) -> Iterator[None]: + """Serve on a worker until the main process receives Ctrl-C.""" + stop_requested = threading.Event() + serving_finished = threading.Event() + serving_errors: list[BaseException] = [] + + def request_shutdown(_signal_number: int, _frame: object) -> None: + stop_requested.set() + + def serve_forever() -> None: + try: + server.serve_forever() + except BaseException as error: + serving_errors.append(error) + finally: + serving_finished.set() + stop_requested.set() + + server_thread = threading.Thread( + target=serve_forever, + name="flyover-preview-server", + daemon=True, + ) + previous_handler = signal.signal(signal.SIGINT, request_shutdown) + thread_started = False + try: + server_thread.start() + thread_started = True + yield + stop_requested.wait() + finally: + try: + if thread_started and not serving_finished.is_set(): + server.shutdown() + if thread_started: + server_thread.join() + finally: + signal.signal(signal.SIGINT, previous_handler) + + if serving_errors: + raise serving_errors[0] + + +def serve( + directory: Path, + *, + lan: bool, + port: int, + output: TextIO = sys.stdout, + server_factory: ServerFactory = ThreadingHTTPServer, + address_provider: Callable[[], tuple[str, ...]] = discover_network_addresses, +) -> None: + artifact = validate_artifact(directory) + host = "0.0.0.0" if lan else "127.0.0.1" + with _open_artifact_root(artifact) as root_descriptor: + handler = partial( + FlyoverRequestHandler, + root_descriptor=root_descriptor, + allowed_paths=artifact.allowed_paths, + ) + try: + server = server_factory((host, port), handler) + except OSError as error: + raise FlyoverArtifactError( + f"could not start the preview server on {host}:{port}: {error}" + ) from error + + with server, _serve_until_interrupted(server): + selected_port = int(server.server_address[1]) + print(f"Flyover preview: {artifact.root}", file=output) + print(f"Local: http://127.0.0.1:{selected_port}/", file=output) + if lan: + addresses = address_provider() + for address in addresses: + print(f"Network: http://{address}:{selected_port}/", file=output) + if not addresses: + print( + "Network: bound to all interfaces, but no LAN address was found.", + file=output, + ) + print("Warning: LAN preview has no authentication or TLS.", file=output) + print("Press Ctrl-C to stop.", file=output, flush=True) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(add_help=False) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate = subparsers.add_parser("validate", add_help=False) + validate.add_argument("directory", type=Path) + validate.add_argument("--without-marker", action="store_true") + + preview = subparsers.add_parser("serve", add_help=False) + preview.add_argument("directory", type=Path) + preview.add_argument("--port", type=int, required=True) + preview.add_argument("--lan", action="store_true") + return parser + + +def main(arguments: list[str] | None = None) -> int: + try: + options = _parser().parse_args(arguments) + if options.command == "validate": + validate_artifact( + options.directory, + require_marker=not options.without_marker, + ) + elif options.command == "serve": + serve(options.directory, lan=options.lan, port=options.port) + return 0 + except FlyoverArtifactError as error: + print(f"flyover: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/test_runner.py b/Tools/test_runner.py index 2b890f83f..2a89cf854 100644 --- a/Tools/test_runner.py +++ b/Tools/test_runner.py @@ -195,6 +195,11 @@ def consume(self, line: str) -> None: self.images += 1 self.emit() return + if line.startswith("FLYOVER_EXPORT "): + self._clear_terminal() + print(f" {line}", file=self.output, flush=True) + self.last_emit = 0.0 + return match = self.SUITE.match(line) if match: if match.group(2) == "started": diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index d9406213b..34d0aa12b 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -68,6 +68,10 @@ Layering, localization, preview, and testing conventions live in the feature factory methods. - Construct and retain the Where Flyover catalog once after its world loads. Never rebuild fixture state from a SwiftUI `body`. +- Build one `WhereFlyoverWorld` for each hosted web export. Reuse it for every + capture. Never activate its scope or read user data. +- Export stable screen IDs from reflected screen and context type names. Use + snapshot case names for snapshot-backed variant IDs. - Present Where Flyover from the developer accordion with `fullScreenCover`. Place it outside the selected-tool `NavigationStack`. - Register leaf screens against Flyover's default navigation container. Use diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 77a4d1265..e8e63decc 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -375,9 +375,9 @@ the happy path. See the feature ## Flyover -`Sources/Developer/Flyover` owns an explicit `WhereFlyoverScreenID` catalog. -The enum is exhaustive and completeness-tested, so adding a top-level screen -produces one obvious registration update rather than depending on source +`Sources/Developer/Flyover` owns an explicit typed screen catalog. +Its registration list is completeness-tested, so adding a top-level screen +produces one obvious update rather than depending on source scanning or a macro that cannot discover navigation across the module. Opening Flyover asynchronously builds one `WhereScope.demo` and shares its @@ -399,6 +399,19 @@ interactive viewport. Flyover's appearance, device, Dynamic Type, contrast, layout-direction, and bold-text choices are session-only and apply only to registered content. +`WhereFlyoverWebExportTests` is the hosted static-export adapter. It reads the +request supplied by `./flyover export` and otherwise returns without work. One +`WhereFlyoverWorld` supplies every capture in an export. Its frozen date, +in-memory store, in-memory preferences, no-op services, private logs, and +Broadway root are the same synthetic fixtures used by native Flyover. The +export never activates that scope or reads app data. + +`WhereFlyoverScreenID.exportIdentifier` maps normal screens to their reflected +type name. Contextual screens include both reflected type names. Snapshot-backed +variant IDs use the snapshot case name. These identifiers are stable web and +deep-link identities; process-local `ObjectIdentifier` values never leave the +runtime catalog. + ## Testing Swift Testing in [`Tests/`](Tests) (`WhereUITests`), hosted in `StuffTestHost` diff --git a/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift b/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift new file mode 100644 index 000000000..8f0d0d447 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift @@ -0,0 +1,347 @@ +import Flyover +import Foundation +import SnapshotKitTesting +import SwiftUI +import Testing +import UIKit +@testable import WhereUI + +@MainActor +struct WhereFlyoverWebExportTests { + @Test func exportsRequestedAtlas() async throws { + guard let environment = try WhereFlyoverExportEnvironment.current() else { + return + } + + let world = try await WhereFlyoverWorld.buildForWebExport() + let catalog = WhereFlyoverCatalog.make(world: world) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "where", + title: "Where", + screenIdentifier: \WhereFlyoverScreenID.exportIdentifier, + ) + let summary = try await exporter.export( + to: environment.outputDirectory, + profiles: environment.profiles, + build: environment.build, + capture: capture, + ) + print( + "FLYOVER_EXPORT_COMPLETE \(summary.screenCount) screens " + + "\(summary.stateCount) states \(summary.imageCount) images " + + "\(summary.outputByteCount) bytes", + ) + } + + @Test func exportsHostedSmokeAtlas() async throws { + let output = FileManager.default.temporaryDirectory + .appending(path: "WhereFlyoverHostedSmoke-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + defer { + do { + try FileManager.default.removeItem(at: output) + } catch { + Issue.record(error) + } + } + try copyWebShell(to: output) + + let catalog = FlyoverCatalog( + groups: [ + FlyoverGroup( + id: FlyoverGroupID("primary"), + title: "Primary", + root: SmokeScreen.root, + screens: [ + FlyoverScreen( + id: SmokeScreen.root, + title: "Root", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("viewport"), + title: "Viewport", + ) { + Text("Root") + }, + ], + ), + ], + ), + FlyoverGroup( + id: FlyoverGroupID("details"), + title: "Details", + root: SmokeScreen.details, + screens: [ + FlyoverScreen( + id: SmokeScreen.details, + title: "Details", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("full-content"), + title: "Full Content", + exportPolicy: FlyoverExportPolicy( + captureExtent: .fullContent, + measurementReadiness: .immediate, + settle: .immediate, + onReadyToMeasure: nil, + onReadyToSnapshot: nil, + ), + ) { + ScrollView { + VStack(spacing: 0) { + Color.red.frame(height: 500) + Color.blue.frame(height: 500) + } + } + }, + ], + ), + ], + ), + ], + transitions: [ + FlyoverTransition(from: .root, to: .details, kind: .push), + FlyoverTransition(from: .details, to: .root, kind: .modal), + ], + ) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "smoke", + title: "Hosted Smoke", + screenIdentifier: \SmokeScreen.rawValue, + ) + let summary = try await exporter.export( + to: output, + profiles: [.phoneLight, .phoneDark], + build: FlyoverExportBuild( + commit: "smoke", + dirty: false, + branch: nil, + generatedAt: "2026-09-03T00:00:00Z", + xcodeVersion: "Tests", + simulatorDevice: "StuffTestHost", + simulatorOS: "Tests", + ), + capture: capture, + ) + + let data = try Data(contentsOf: output.appending(path: "manifest.json")) + let manifest = try JSONDecoder().decode(FlyoverWebManifest.self, from: data) + #expect(manifest.schemaVersion == 1) + #expect(summary.screenCount == 2) + #expect(summary.stateCount == 2) + #expect(summary.profileCount == 2) + #expect(summary.imageCount == 4) + #expect(summary.outputByteCount > 0) + #expect(summary.outputDirectory == output) + #expect(manifest.profiles.map(\.id) == ["phone-light", "phone-dark"]) + #expect(manifest.groups.map { group in + [group.id, group.rootScreenID] + group.screenIDs + } == [ + ["primary", "root", "root"], + ["details", "details", "details"], + ]) + + let root = try #require(manifest.screens.first { $0.id == "root" }) + let details = try #require(manifest.screens.first { $0.id == "details" }) + let rootVariant = try #require(root.variants.first) + let detailsVariant = try #require(details.variants.first) + #expect(rootVariant.id == "viewport") + #expect(rootVariant.captureExtent == "viewport") + #expect(rootVariant.imagesByProfile == [ + "phone-light": "images/screen-0001/variant-0001/phone-light.png", + "phone-dark": "images/screen-0001/variant-0001/phone-dark.png", + ]) + #expect(detailsVariant.id == "full-content") + #expect(detailsVariant.captureExtent == "fullContent") + #expect(detailsVariant.imagesByProfile == [ + "phone-light": "images/screen-0002/variant-0001/phone-light.png", + "phone-dark": "images/screen-0002/variant-0001/phone-dark.png", + ]) + + #expect(manifest.routes.map { route in + [ + route.id, + route.sourceScreenID, + route.destinationScreenID, + route.kind, + route.label ?? "", + ] + } == [ + ["route-0001", "root", "details", "push", ""], + ["route-0002", "details", "root", "modal", ""], + ]) + #expect(root.incomingRouteIDs == ["route-0002"]) + #expect(root.outgoingRouteIDs == ["route-0001"]) + #expect(details.incomingRouteIDs == ["route-0001"]) + #expect(details.outgoingRouteIDs == ["route-0002"]) + #expect(manifest.canvas.connectors.map(\.routeID) == ["route-0001", "route-0002"]) + + #expect(manifest.images.count == 4) + #expect(manifest.images.map { image in + [ + image.screenID, + image.variantID, + image.profileID, + image.relativePath, + image.captureExtent, + ] + } == [ + [ + "root", + "viewport", + "phone-light", + "images/screen-0001/variant-0001/phone-light.png", + "viewport", + ], + [ + "root", + "viewport", + "phone-dark", + "images/screen-0001/variant-0001/phone-dark.png", + "viewport", + ], + [ + "details", + "full-content", + "phone-light", + "images/screen-0002/variant-0001/phone-light.png", + "fullContent", + ], + [ + "details", + "full-content", + "phone-dark", + "images/screen-0002/variant-0001/phone-dark.png", + "fullContent", + ], + ]) + for image in manifest.images { + #expect(image.relativePath.hasPrefix("/") == false) + #expect(image.pointWidth == 402) + if image.screenID == "root" { + #expect(image.pointHeight == 874) + } else { + #expect(image.pointHeight > 874) + } + #expect(image.pixelWidth == Int((image.pointWidth * image.scale).rounded())) + #expect(image.pixelHeight == Int((image.pointHeight * image.scale).rounded())) + + let imageURL = output.appending(path: image.relativePath) + let pngData = try Data(contentsOf: imageURL) + let decodedImage = try #require(UIImage(data: pngData, scale: CGFloat(image.scale))) + let pixels = try #require(decodedImage.cgImage) + #expect(pixels.width == image.pixelWidth) + #expect(pixels.height == image.pixelHeight) + } + for path in [ + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", + ] { + #expect(FileManager.default.fileExists( + atPath: output.appending(path: path).path, + )) + } + let shell = try ["index.html", "assets/app.js", "assets/styles.css"] + .map { path in try String(contentsOf: output.appending(path: path), encoding: .utf8) } + .joined(separator: "\n") + .replacingOccurrences(of: "http://www.w3.org/2000/svg", with: "") + #expect(shell.contains("http://") == false) + #expect(shell.contains("https://") == false) + #expect(shell.contains("fetch(") == false) + } + + private func capture(_ request: FlyoverCaptureRequest) async throws -> FlyoverCapturedImage { + let capture = try await captureSnapshotPNG( + of: request.content, + configuration: request.configuration, + named: request.captureName, + sizing: request.configuration.snapshotSizing, + safeAreaInsets: request.configuration.device.safeAreaInsets.uiEdgeInsets, + measurementReadiness: request.measurementReadiness, + onReadyToMeasure: request.onReadyToMeasure, + settle: request.settle, + onReadyToSnapshot: request.onReadyToSnapshot, + ) + return FlyoverCapturedImage( + pngData: capture.data, + pointSize: capture.pointSize, + pixelSize: capture.pixelSize, + scale: capture.scale, + ) + } + + private func copyWebShell(to output: URL) throws { + let repository = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = repository.appending(path: "Shared/Flyover/Web") + let assets = output.appending(path: "assets", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + try FileManager.default.copyItem( + at: source.appending(path: "index.html"), + to: output.appending(path: "index.html"), + ) + for filename in ["app.js", "styles.css"] { + try FileManager.default.copyItem( + at: source.appending(path: "assets/\(filename)"), + to: assets.appending(path: filename), + ) + } + } +} + +private enum SmokeScreen: String { + case root + case details +} + +private struct WhereFlyoverExportEnvironment { + let outputDirectory: URL + let profiles: [FlyoverCaptureProfile] + let build: FlyoverExportBuild + + static func current() throws -> Self? { + let values = ProcessInfo.processInfo.environment + guard let output = values["FLYOVER_EXPORT_DIRECTORY"], output.isEmpty == false else { + return nil + } + let identifiers = values["FLYOVER_EXPORT_PROFILES"]? + .split(separator: ",") + .map(String.init) ?? [] + return try WhereFlyoverExportEnvironment( + outputDirectory: URL(filePath: output, directoryHint: .isDirectory), + profiles: FlyoverCaptureProfile.parse(identifiers), + build: FlyoverExportBuild( + commit: values["FLYOVER_EXPORT_COMMIT"] ?? "unknown", + dirty: values["FLYOVER_EXPORT_DIRTY"] == "true", + branch: values["FLYOVER_EXPORT_BRANCH"].flatMap { $0.isEmpty ? nil : $0 }, + generatedAt: values["FLYOVER_EXPORT_GENERATED_AT"] ?? "unknown", + xcodeVersion: values["FLYOVER_EXPORT_XCODE_VERSION"] ?? "unknown", + simulatorDevice: values["FLYOVER_EXPORT_SIMULATOR_DEVICE"] ?? "unknown", + simulatorOS: values["FLYOVER_EXPORT_SIMULATOR_OS"] ?? "unknown", + ), + ) + } +} + +extension SnapshotConfiguration { + fileprivate var snapshotSizing: SnapshotSizing { + switch device.size { + case .fixed: + .fixed + case let .intrinsic(maxWidth): + .intrinsic(width: maxWidth ?? UIScreen.main.bounds.width, minimumHeight: 0) + case let .fullContent(width, minimumHeight): + .intrinsic(width: width, minimumHeight: minimumHeight ?? 0) + case let .fullContent2D(minimumSize): + .fullContent2D(minimumSize: minimumSize) + } + } +} diff --git a/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift b/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift index 6b8fc5ae6..c8320ca43 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift @@ -6,8 +6,8 @@ static let flyoverData = WhereFlyoverData.hosted( OpenSpansView.self, title: "Open Spans", - ) { _ in - OpenSpansView(system: .shared) + ) { world in + OpenSpansView(system: world.openSpansLogSystem) } } #endif diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift index 72eb76f49..e052ab3b6 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift @@ -77,9 +77,9 @@ title: title, viewport: viewport, navigationContainer: navigationContainer, - variants: Screen.snapshots.enumerated().map { index, snapshotCase in + variants: Screen.snapshots.map { snapshotCase in FlyoverVariant( - id: FlyoverVariantID("\(id).\(index)"), + id: FlyoverVariantID(snapshotCase.name), snapshotCase: snapshotCase, ) }, diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift index dfcef09af..d3f37011a 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift @@ -21,6 +21,10 @@ typeName } + var exportIdentifier: String { + typeName + } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.value == rhs.value } diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift index 6d9c57711..69bc502cb 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift @@ -7,6 +7,7 @@ @MainActor final class WhereFlyoverWorld { let scope: WhereScope + let openSpansLogSystem: Periscope let model: WhereModel let session: WhereSession let report: YearReportModel @@ -15,6 +16,7 @@ private init( scope: WhereScope, + openSpansLogSystem: Periscope, model: WhereModel, session: WhereSession, report: YearReportModel, @@ -22,6 +24,7 @@ backup: BackupModel, ) { self.scope = scope + self.openSpansLogSystem = openSpansLogSystem self.model = model self.session = session self.report = report @@ -30,6 +33,16 @@ } static func build() async throws -> WhereFlyoverWorld { + try await build(openSpansSource: .shared) + } + + static func buildForWebExport() async throws -> WhereFlyoverWorld { + try await build(openSpansSource: .syntheticWorld) + } + + private static func build( + openSpansSource: OpenSpansSource, + ) async throws -> WhereFlyoverWorld { let now: @Sendable () -> Date = { PreviewSupport.referenceNow } let logSystem = Periscope( configuration: Periscope.Configuration(), @@ -61,9 +74,14 @@ now: now, ) await report.activate() + let openSpansLogSystem: Periscope = switch openSpansSource { + case .shared: .shared + case .syntheticWorld: logSystem + } return WhereFlyoverWorld( scope: scope, + openSpansLogSystem: openSpansLogSystem, model: model, session: session, report: report, @@ -104,6 +122,7 @@ ) return WhereFlyoverWorld( scope: scope, + openSpansLogSystem: .shared, model: model, session: session, report: report, @@ -115,5 +134,10 @@ backup: BackupModel(services: services), ) } + + private enum OpenSpansSource { + case shared + case syntheticWorld + } } #endif diff --git a/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift b/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift index 0007aa3f2..a366c4b9c 100644 --- a/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift @@ -14,6 +14,8 @@ #expect(Set(registered) == Set(declared)) #expect(registered.count == declared.count) #expect(declared.count == Set(declared).count) + let exportIdentifiers = catalog.screens.map(\.id.exportIdentifier) + #expect(exportIdentifiers.count == Set(exportIdentifiers).count) } @Test func recordsOnlyForwardPushAndModalRoutes() async throws { diff --git a/Where/WhereUI/Tests/WhereFlyoverDataTests.swift b/Where/WhereUI/Tests/WhereFlyoverDataTests.swift index d8e272e1d..be6b6cbb4 100644 --- a/Where/WhereUI/Tests/WhereFlyoverDataTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverDataTests.swift @@ -1,5 +1,6 @@ #if DEBUG import Flyover + import SnapshotKit import SwiftUI import Testing @testable import WhereUI @@ -29,5 +30,28 @@ #expect(transitions[1].destination == presented) #expect(transitions[1].kind == .modal) } + + @Test func snapshotVariantsUseSnapshotNamesAsStableIdentifiers() { + let data = WhereFlyoverData.snapshots( + SnapshotScreen.self, + title: "Snapshot", + ) + + let screen = data.screen(in: .preview()) + #expect(screen.variants.map(\.id.rawValue) == ["First", "Second"]) + } + + private struct SnapshotScreen: View, SnapshotProviding { + var body: some View { + EmptyView() + } + + static var snapshots: [SnapshotCase] { + [ + SnapshotCase(name: "First", configurations: []) { EmptyView() }, + SnapshotCase(name: "Second", configurations: []) { EmptyView() }, + ] + } + } } #endif diff --git a/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift b/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift index c5b393b5c..1cdbab99e 100644 --- a/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift @@ -15,6 +15,12 @@ #expect(Set([first, same, second, contextual]).count == 3) #expect(first.description.contains("FirstScreen")) #expect(contextual.description.contains("SecondScreen")) + #expect(first.exportIdentifier == String(reflecting: FirstScreen.self)) + #expect( + contextual.exportIdentifier + == + "\(String(reflecting: FirstScreen.self)) in \(String(reflecting: SecondScreen.self))", + ) } private enum FirstScreen {} diff --git a/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift b/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift index c0a116d29..4c311f8d6 100644 --- a/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift @@ -1,4 +1,5 @@ #if DEBUG + import PeriscopeCore import Testing @testable import WhereUI @@ -13,5 +14,13 @@ #expect(world.model.activeScope !== world.scope) #expect(world.report.report?.days.isEmpty == false) } + + @Test func keepsNativeSpansLiveAndWebExportSpansSynthetic() async throws { + let nativeWorld = try await WhereFlyoverWorld.build() + #expect(nativeWorld.openSpansLogSystem === Periscope.shared) + + let exportWorld = try await WhereFlyoverWorld.buildForWebExport() + #expect(exportWorld.openSpansLogSystem !== Periscope.shared) + } } #endif diff --git a/flyover b/flyover new file mode 100755 index 000000000..68f907b12 --- /dev/null +++ b/flyover @@ -0,0 +1,374 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd -P)" +DEFAULT_OUTPUT=".build/flyover/where" +DEFAULT_PROFILES=(phone-light phone-dark) +OUTPUT="$DEFAULT_OUTPUT" +PROFILES=() +STAGING="" +BACKUP="" +BACKUP_CONTAINER="" +DESTINATION="" +COMMAND="" +PREVIEW_LAN=false +PREVIEW_PORT=0 + +usage() { + cat <<'USAGE' +Usage: + ./flyover export [options] + ./flyover preview [options] + ./flyover --help + +Export or preview Where's native Flyover catalog as a static website. + +Commands: + export Capture the catalog and write a portable website. + preview Serve an existing export on a local web server. + +Export options: + --output DIR Generated website directory (default: .build/flyover/where) + --profile NAME Add a capture profile. Repeat the flag for more profiles. + The defaults are phone-light and phone-dark. + +Preview options: + --output DIR Existing generated directory (default: .build/flyover/where) + --port PORT TCP port. The default selects a free port. + --lan Also serve on this computer's local network interfaces. + +General options: + -h, --help Show this help. + +Profiles: + phone-light, phone-dark, tablet-light, phone-landscape, phone-small, + phone-xxxl, phone-ax3, phone-contrast, phone-rtl, phone-bold, + phone-voiceover + +Examples: + ./flyover export + ./flyover export --profile phone-light --profile phone-dark + ./flyover export --output /tmp/where-flyover --profile tablet-light + ./flyover preview + ./flyover preview --lan + ./flyover preview --output /tmp/where-flyover --lan --port 8080 +USAGE +} + +export_usage() { + cat <<'USAGE' +Usage: ./flyover export [options] + +Capture Where's native Flyover catalog as a portable static website. + +Options: + --output DIR Generated website directory (default: .build/flyover/where) + --profile NAME Add a capture profile. Repeat the flag for more profiles. + The defaults are phone-light and phone-dark. + -h, --help Show this help. + +Profiles: + phone-light, phone-dark, tablet-light, phone-landscape, phone-small, + phone-xxxl, phone-ax3, phone-contrast, phone-rtl, phone-bold, + phone-voiceover + +Examples: + ./flyover export + ./flyover export --profile phone-light --profile phone-dark + ./flyover export --output /tmp/where-flyover --profile tablet-light +USAGE +} + +preview_usage() { + cat <<'USAGE' +Usage: ./flyover preview [options] + +Serve an existing Flyover export on a local web server. + +Options: + --output DIR Existing generated directory (default: .build/flyover/where) + --port PORT TCP port from 0 through 65535. The default 0 selects a free port. + --lan Also bind to local network interfaces and print network URLs. + -h, --help Show this help. + +The default server accepts connections from this computer only. LAN preview +has no authentication or TLS. Press Ctrl-C to stop the server. +USAGE +} + +error() { + echo "flyover: $*" >&2 + exit 1 +} + +is_known_profile() { + case "$1" in + phone-light | phone-dark | tablet-light | phone-landscape | phone-small | \ + phone-xxxl | phone-ax3 | phone-contrast | phone-rtl | phone-bold | \ + phone-voiceover) + return 0 + ;; + *) return 1 ;; + esac +} + +add_profile() { + local candidate="$1" existing + is_known_profile "$candidate" || error "unknown profile '$candidate' (see ./flyover --help)" + for existing in ${PROFILES[@]+"${PROFILES[@]}"}; do + [ "$existing" = "$candidate" ] && return 0 + done + PROFILES+=("$candidate") +} + +resolve_path() { + python3 - "$1" <<'PY' +import os, sys +print(os.path.realpath(os.path.abspath(os.path.expanduser(sys.argv[1])))) +PY +} + +paths_are_same() { + python3 - "$1" "$2" <<'PY' +import os, sys +first, second = sys.argv[1:] +if first == second: + raise SystemExit(0) +try: + raise SystemExit(0 if os.path.samefile(first, second) else 1) +except OSError: + raise SystemExit(1) +PY +} + +validate_destination() { + if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then + [ -d "$DESTINATION" ] \ + || error "refusing to replace non-directory '$DESTINATION'" + local marker="$DESTINATION/.flyover-generated" marker_content + [ ! -L "$marker" ] \ + || error "refusing to replace directory with a symbolic-link marker '$DESTINATION'" + [ -f "$marker" ] \ + || error "refusing to replace unmarked directory '$DESTINATION'" + marker_content="$(< "$marker")" + [ "$marker_content" = "schemaVersion=1" ] \ + || error "refusing to replace directory with an unsupported marker '$DESTINATION'" + fi +} + +cleanup() { + if [ -n "$STAGING" ] && [ -d "$STAGING" ]; then + rm -rf "$STAGING" + fi + if [ -n "$BACKUP" ] && [ -e "$BACKUP" ] && [ -n "$DESTINATION" ] && [ ! -e "$DESTINATION" ]; then + mv "$BACKUP" "$DESTINATION" + BACKUP="" + fi + if [ -n "$BACKUP_CONTAINER" ] && [ -d "$BACKUP_CONTAINER" ]; then + if ! rmdir "$BACKUP_CONTAINER" 2>/dev/null; then + echo "flyover: warning: could not remove temporary replacement directory '$BACKUP_CONTAINER'" >&2 + fi + fi +} +trap cleanup EXIT INT TERM + +case "${1-}" in + -h | --help) + usage + exit 0 + ;; + export) + COMMAND="export" + shift + ;; + preview) + COMMAND="preview" + shift + ;; + "") + usage >&2 + exit 1 + ;; + *) error "unknown command '${1-}' (see ./flyover --help)" ;; +esac + +if [ "$COMMAND" = preview ]; then + while [ "$#" -gt 0 ]; do + case "$1" in + --output) + shift + [ "$#" -gt 0 ] || error "--output requires a directory" + OUTPUT="$1" + ;; + --port) + shift + [ "$#" -gt 0 ] || error "--port requires a number" + PREVIEW_PORT="$1" + ;; + --lan) + PREVIEW_LAN=true + ;; + -h | --help) + preview_usage + exit 0 + ;; + *) error "unknown preview option '$1' (see ./flyover preview --help)" ;; + esac + shift + done + + case "$PREVIEW_PORT" in + "" | *[!0-9]*) error "--port must be a number from 0 through 65535" ;; + esac + [ "$PREVIEW_PORT" -le 65535 ] \ + || error "--port must be a number from 0 through 65535" + + DESTINATION="$(resolve_path "$OUTPUT")" + HOME_PATH="$(resolve_path "$HOME")" + WORKSPACE_PATH="$(resolve_path "$ROOT")" + paths_are_same "$DESTINATION" / && error "refusing to serve the filesystem root" + paths_are_same "$DESTINATION" "$HOME_PATH" && error "refusing to serve the home directory" + paths_are_same "$DESTINATION" "$WORKSPACE_PATH" \ + && error "refusing to serve the workspace root" + + PREVIEW_ARGUMENTS=( + "$ROOT/Tools/flyover_preview.py" + serve + "$DESTINATION" + --port + "$PREVIEW_PORT" + ) + if [ "$PREVIEW_LAN" = true ]; then + PREVIEW_ARGUMENTS+=(--lan) + fi + exec env PYTHONDONTWRITEBYTECODE=1 \ + "${FLYOVER_PREVIEW_PYTHON:-python3}" "${PREVIEW_ARGUMENTS[@]}" +fi + +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + shift + [ "$#" -gt 0 ] || error "--output requires a directory" + OUTPUT="$1" + ;; + --profile) + shift + [ "$#" -gt 0 ] || error "--profile requires a name" + add_profile "$1" + ;; + -h | --help) + export_usage + exit 0 + ;; + *) error "unknown option '$1' (see ./flyover --help)" ;; + esac + shift +done + +[ "${#PROFILES[@]}" -gt 0 ] || PROFILES=("${DEFAULT_PROFILES[@]}") +DESTINATION="$(resolve_path "$OUTPUT")" +HOME_PATH="$(resolve_path "$HOME")" +WORKSPACE_PATH="$(resolve_path "$ROOT")" +paths_are_same "$DESTINATION" / && error "refusing to replace the filesystem root" +paths_are_same "$DESTINATION" "$HOME_PATH" && error "refusing to replace the home directory" +paths_are_same "$DESTINATION" "$WORKSPACE_PATH" \ + && error "refusing to replace the workspace root" +validate_destination + +COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" +GIT_STATUS_ARGUMENTS=(status --porcelain --untracked-files=all -- .) +if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then + DESTINATION_EXCLUSION="$(python3 - "$ROOT" "$DESTINATION" <<'PY' +import pathlib, sys +root, destination = map(pathlib.Path, sys.argv[1:]) +try: + relative = destination.relative_to(root) +except ValueError: + pass +else: + if relative.parts: + print(":(exclude,top,literal)" + relative.as_posix()) +PY +)" + if [ -n "$DESTINATION_EXCLUSION" ]; then + GIT_STATUS_ARGUMENTS+=("$DESTINATION_EXCLUSION") + fi +fi +if ! GIT_STATUS="$(git -C "$ROOT" "${GIT_STATUS_ARGUMENTS[@]}" 2>/dev/null)"; then + error "could not read the Git working-tree status" +fi +if [ -n "$GIT_STATUS" ]; then + DIRTY=true +else + DIRTY=false +fi +BRANCH="$(git -C "$ROOT" branch --show-current 2>/dev/null || true)" +GENERATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +XCODE_VERSION="${FLYOVER_XCODE_VERSION_OVERRIDE:-$(xcodebuild -version 2>/dev/null | tr '\n' ' ' | sed 's/ $//' || true)}" +[ -n "$XCODE_VERSION" ] || XCODE_VERSION=unknown +PROFILE_LIST="$(IFS=,; echo "${PROFILES[*]}")" +CAPTURE_RUNNER="${FLYOVER_CAPTURE_RUNNER:-$ROOT/test}" +EXPORT_STARTED_AT=$SECONDS + +PARENT="$(dirname "$DESTINATION")" +mkdir -p "$PARENT" +STAGING="$(mktemp -d "$PARENT/.flyover-staging.XXXXXX")" +chmod 0755 "$STAGING" +mkdir -p "$STAGING/assets" +cp "$ROOT/Shared/Flyover/Web/index.html" "$STAGING/index.html" +cp "$ROOT/Shared/Flyover/Web/assets/app.js" "$STAGING/assets/app.js" +cp "$ROOT/Shared/Flyover/Web/assets/styles.css" "$STAGING/assets/styles.css" + +echo "==> Exporting ${#PROFILES[@]} profile(s) to $DESTINATION" +env \ + FLYOVER_EXPORT_DIRECTORY="$STAGING" \ + FLYOVER_EXPORT_PROFILES="$PROFILE_LIST" \ + FLYOVER_EXPORT_COMMIT="$COMMIT" \ + FLYOVER_EXPORT_DIRTY="$DIRTY" \ + FLYOVER_EXPORT_BRANCH="$BRANCH" \ + FLYOVER_EXPORT_GENERATED_AT="$GENERATED_AT" \ + FLYOVER_EXPORT_XCODE_VERSION="$XCODE_VERSION" \ + FLYOVER_EXPORT_SIMULATOR_DEVICE="iPhone 17" \ + FLYOVER_EXPORT_SIMULATOR_OS="27.0" \ + "$CAPTURE_RUNNER" --only \ + 'WhereUISnapshotTests/WhereFlyoverWebExportTests/exportsRequestedAtlas()' + +env PYTHONDONTWRITEBYTECODE=1 \ + python3 "$ROOT/Tools/flyover_preview.py" validate "$STAGING" --without-marker + +printf '%s\n' 'schemaVersion=1' >"$STAGING/.flyover-generated" +find "$STAGING" -type d -exec chmod 0755 {} + +find "$STAGING" -type f -exec chmod 0644 {} + + +validate_destination +if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then + BACKUP_CONTAINER="$(mktemp -d "$PARENT/.flyover-replacement.XXXXXX")" + chmod 0700 "$BACKUP_CONTAINER" + BACKUP="$BACKUP_CONTAINER/previous" + mv "$DESTINATION" "$BACKUP" +fi +mv "$STAGING" "$DESTINATION" +STAGING="" +if [ -n "$BACKUP" ]; then + rm -rf "$BACKUP" + BACKUP="" +fi +if [ -n "$BACKUP_CONTAINER" ]; then + rmdir "$BACKUP_CONTAINER" + BACKUP_CONTAINER="" +fi + +ELAPSED_SECONDS=$((SECONDS - EXPORT_STARTED_AT)) +python3 - "$DESTINATION" "$ELAPSED_SECONDS" <<'PY' +import json, pathlib, sys +root = pathlib.Path(sys.argv[1]) +elapsed = int(sys.argv[2]) +manifest = json.loads((root / 'manifest.json').read_text()) +size = sum(path.stat().st_size for path in root.rglob('*') if path.is_file()) +print(f"Flyover export complete: {len(manifest['screens'])} screens, " + f"{sum(len(screen['variants']) for screen in manifest['screens'])} states, " + f"{len(manifest['profiles'])} profiles, {len(manifest['images'])} images, " + f"{size} bytes in {elapsed}s") +print(root) +PY diff --git a/loc b/loc index 14a025c87..941c9713e 100755 --- a/loc +++ b/loc @@ -19,6 +19,7 @@ ROOT_SCRIPTS=( attribution codex-watchdog flaky + flyover icons ide loc diff --git a/test b/test index 39b6169a4..97c3cf215 100755 --- a/test +++ b/test @@ -91,7 +91,8 @@ Runs this repo's tests against the simulator this checkout owns, streaming progress as it goes. With no arguments it runs only the bundles affected by the working tree's changes. Every normal invocation first validates and tests the Bumper Bowling rules, then enforces the architecture. Unit and affected scopes -also run the fast host-side backup-upgrader regression suite. +also run the fast host-side backup-upgrader regression suite. Every normal +invocation runs the fast Flyover command regression suite. Scope: (no arguments) Bundles affected by the diff against origin/main, including @@ -268,6 +269,11 @@ if [ "$ARCHITECTURE_ONLY" = true ]; then exit 0 fi +if [ -f Shared/Flyover/Tools/Tests/flyover_test.sh ]; then + echo "==> Testing flyover command" + bash Shared/Flyover/Tools/Tests/flyover_test.sh +fi + WORKSPACE="Stuff.xcworkspace" UNIT_SCHEME="Stuff-iOS-Tests" SNAPSHOT_SCHEME="StuffSnapshotTests" @@ -508,6 +514,12 @@ RUN_ENV=() [ "$REVIEW" = true ] && RUN_ENV+=("TEST_RUNNER_SNAPSHOT_DIFF=1") [ -n "${SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER:-}" ] \ && RUN_ENV+=("TEST_RUNNER_SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER=$SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER") +for flyover_name in DIRECTORY PROFILES COMMIT DIRTY BRANCH GENERATED_AT XCODE_VERSION SIMULATOR_DEVICE SIMULATOR_OS; do + flyover_variable="FLYOVER_EXPORT_$flyover_name" + if [ -n "${!flyover_variable:-}" ]; then + RUN_ENV+=("TEST_RUNNER_$flyover_variable=${!flyover_variable}") + fi +done # SwiftPM's generated `Bundle.module` accessors honor # PACKAGE_RESOURCE_BUNDLE_PATH as their first lookup candidate (DEBUG-only,