diff --git a/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md b/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md new file mode 100644 index 0000000000..3ce290fc5c --- /dev/null +++ b/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md @@ -0,0 +1,18 @@ +# Application Graphic Assets License + +Copyright (C) 2026 SimpleX Chat Ltd. All rights reserved. + +The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, visual designs, and screenshots of the applications, such as the snapshot images of the tests - are proprietary and are not licensed under the AGPLv3 that covers the application source code. + +## Permitted use + +- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. The snapshot images of the tests are not needed to build the application and are not part of it. +- Publications with permission. You may use screenshots containing these assets in publications with prior written permission from SimpleX Chat Ltd. + +## Not permitted + +All other use, including modification, redistribution, or incorporation into other works, is not permitted without prior written permission from SimpleX Chat Ltd. + +## Contact + +To request permission, contact chat@simplex.chat. diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png new file mode 100644 index 0000000000..e742c5dea6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..b856951159 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png new file mode 100644 index 0000000000..9d4f92bbac Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-accessibility-medium.png new file mode 100644 index 0000000000..ec16ffc3bf Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..b502b6aaf1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png new file mode 100644 index 0000000000..7a62931eb3 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/SnapshotTests.swift b/apps/ios/SimpleX Snapshots/SnapshotTests.swift new file mode 100644 index 0000000000..371fe1f51a --- /dev/null +++ b/apps/ios/SimpleX Snapshots/SnapshotTests.swift @@ -0,0 +1,104 @@ +import XCTest +import SwiftUI +import UIKit +@testable import SimpleX +@testable import SimpleXChat + +final class SnapshotTests: XCTestCase { + @MainActor + func testPreviews() throws { + NSTimeZone.default = try XCTUnwrap(TimeZone(identifier: "UTC")) + let env = ProcessInfo.processInfo.environment + let dir = URL(fileURLWithPath: env["SNAPSHOT_DIR"] ?? NSTemporaryDirectory() + "snapshots", isDirectory: true) + let size = env["SNAPSHOT_SIZE"] ?? "large" + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let windowScene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + var index: [[String: Any]] = [] + for dark in [false, true] { + let theme = snapshotTheme(dark) + CurrentColors = theme + for scene in previewScenes() { + let themeName = dark ? "dark" : "light" + let file = "\(scene.name)-\(themeName)-\(size).png" + let image = try renderScene(scene, theme.toAppTheme(), dark: dark, in: windowScene) + try XCTUnwrap(image.pngData()).write(to: dir.appendingPathComponent(file)) + index.append([ + "scene": scene.name, + "theme": themeName, + "size": size, + "file": file, + "width": Int((image.size.width * image.scale).rounded()), + "height": Int((image.size.height * image.scale).rounded()), + "scale": image.scale, + "contentSizeCategory": UIApplication.shared.preferredContentSizeCategory.rawValue + ]) + } + } + let data = try JSONSerialization.data(withJSONObject: index, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: dir.appendingPathComponent("index-\(size).json")) + } +} + +private struct SnapshotScene { + var name: String + var height: CGFloat? = nil + var view: AnyView +} + +private func previewScenes() -> [SnapshotScene] { + [ + SnapshotScene(name: "ChatInfoImage", view: AnyView(ChatInfoImage_Previews.previews)) + ] +} + +private func snapshotTheme(_ dark: Bool) -> ThemeManager.ActiveTheme { + let base: DefaultTheme = dark ? .DARK : .LIGHT + return ThemeManager.ActiveTheme( + name: base.themeName, + base: base, + colors: (dark ? DarkColorPalette : LightColorPalette).clone(), + appColors: (dark ? DarkColorPaletteApp : LightColorPaletteApp).clone(), + wallpaper: AppWallpaper(background: nil, tint: nil, type: .empty) + ) +} + +@MainActor +private func renderScene(_ scene: SnapshotScene, _ theme: AppTheme, dark: Bool, in windowScene: UIWindowScene) throws -> UIImage { + let screen = windowScene.screen + let width = screen.bounds.width + let root = scene.view + .environmentObject(ChatModel.shared) + .environmentObject(theme) + .frame(width: width, height: scene.height) + let host = UIHostingController(rootView: AnyView(root)) + if #available(iOS 16.4, *) { + host.safeAreaRegions = [] + } + let window = UIWindow(windowScene: windowScene) + window.overrideUserInterfaceStyle = dark ? .dark : .light + window.rootViewController = host + window.frame = CGRect(x: 0, y: 0, width: width, height: scene.height ?? screen.bounds.height) + window.isHidden = false + settle() + if scene.height == nil { + for _ in 0..<2 { + let fit = host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + window.frame = CGRect(x: 0, y: 0, width: width, height: ceil(fit.height)) + settle() + } + } + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.preferredRange = .standard + format.opaque = true + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in + _ = window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + window.isHidden = true + window.rootViewController = nil + return image +} + +private func settle() { + RunLoop.main.run(until: Date().addingTimeInterval(0.6)) +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a3c29d98df..35557ebfbc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -268,6 +268,7 @@ E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A0B0022F960000AAAA0001 /* YourNetwork.swift */; }; E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; }; + E5AEC0C22F9C000100270665 /* SnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0C12F9C000100270665 /* SnapshotTests.swift */; }; E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; @@ -285,6 +286,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + E5AEC0C92F9C000100270665 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CA059C9279559F40002BEB4; + remoteInfo = "SimpleX (iOS)"; + }; 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; @@ -662,6 +670,8 @@ E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; E5A0B0022F960000AAAA0001 /* YourNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YourNetwork.swift; sourceTree = ""; }; E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; + E5AEC0C12F9C000100270665 /* SnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTests.swift; sourceTree = ""; }; + E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SimpleX Snapshots.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; @@ -752,6 +762,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C62F9C000100270665 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD422818589900503DA2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -939,6 +956,7 @@ 5CDCAD462818589900503DA2 /* SimpleX NSE */, CEE723A82C3BD3D70009AE93 /* SimpleX SE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, + E5AEC0C42F9C000100270665 /* SimpleX Snapshots */, 5CE2BA692845308900EC33A6 /* SimpleXChat */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, @@ -968,6 +986,7 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */, 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */, CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */, @@ -984,6 +1003,14 @@ path = "Tests iOS"; sourceTree = ""; }; + E5AEC0C42F9C000100270665 /* SimpleX Snapshots */ = { + isa = PBXGroup; + children = ( + E5AEC0C12F9C000100270665 /* SnapshotTests.swift */, + ); + path = "SimpleX Snapshots"; + sourceTree = ""; + }; 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { isa = PBXGroup; children = ( @@ -1364,6 +1391,24 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + E5AEC0C82F9C000100270665 /* SimpleX Snapshots */ = { + isa = PBXNativeTarget; + buildConfigurationList = E5AEC0CB2F9C000100270665 /* Build configuration list for PBXNativeTarget "SimpleX Snapshots" */; + buildPhases = ( + E5AEC0C52F9C000100270665 /* Sources */, + E5AEC0C62F9C000100270665 /* Frameworks */, + E5AEC0C72F9C000100270665 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E5AEC0CA2F9C000100270665 /* PBXTargetDependency */, + ); + name = "SimpleX Snapshots"; + productName = "SimpleX Snapshots"; + productReference = E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 5CDCAD442818589900503DA2 /* SimpleX NSE */ = { isa = PBXNativeTarget; buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */; @@ -1451,6 +1496,10 @@ CEE723A62C3BD3D70009AE93 = { CreatedOnToolsVersion = 15.4; }; + E5AEC0C82F9C000100270665 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 5CA059C9279559F40002BEB4; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -1493,6 +1542,7 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, + E5AEC0C82F9C000100270665 /* SimpleX Snapshots */, 5CDCAD442818589900503DA2 /* SimpleX NSE */, CEE723A62C3BD3D70009AE93 /* SimpleX SE */, 5CE2BA672845308900EC33A6 /* SimpleXChat */, @@ -1521,6 +1571,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C72F9C000100270665 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD432818589900503DA2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1793,6 +1850,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C52F9C000100270665 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5AEC0C22F9C000100270665 /* SnapshotTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD412818589900503DA2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1846,6 +1911,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + E5AEC0CA2F9C000100270665 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; + targetProxy = E5AEC0C92F9C000100270665 /* PBXContainerItemProxy */; + }; 5CA059D9279559F40002BEB4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; @@ -2285,6 +2355,43 @@ }; name = Release; }; + E5AEC0CC2F9C000100270665 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.snapshots"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleX"; + }; + name = Debug; + }; + E5AEC0CD2F9C000100270665 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.snapshots"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleX"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 5CDCAD4E2818589900503DA2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2564,6 +2671,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E5AEC0CB2F9C000100270665 /* Build configuration list for PBXNativeTarget "SimpleX Snapshots" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E5AEC0CC2F9C000100270665 /* Debug */, + E5AEC0CD2F9C000100270665 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index 6a1d4192e6..6815864bf4 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:SimpleX.xcodeproj"> + + + + --log` and `gh run download -n `. +Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log, the snapshots in `snapshots/`), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. ## How long it takes, what it costs - `plan` (ubuntu-24.04, 10 s) reads what the run asks for, names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. - `core` (macOS, 30–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). - `backend` (macOS, 20–45 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. -- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the backend start about 10 min when asked. +- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min with the snapshots a few minutes more, the backend start about 10 min when asked. - `claude` (macOS, only when asked, after `app` passed): a new simulator with the app job's build and the smoke test, about 7 min, then the sessions (the smoke plan's took 2.6 min). A run with reused builds and no extras takes about 25 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`: builds are reused only as the artifacts above, and a core or backend rebuild compiles its dependencies too. @@ -38,6 +38,20 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; `ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen. +## Snapshots: `snapshots.sh` + +At the end of the app job's smoke test, `smoke-test.sh` runs `snapshots.sh /snapshots` when the app it tested lies in the derived data of a build (`…/Build/Products/Debug-iphonesimulator`); the Claude job, which installs the app from a tarball, skips it. + +`snapshots.sh` builds the unit-test target `SimpleX Snapshots` (`apps/ios/SimpleX Snapshots/SnapshotTests.swift`, hosted by the app) with `xcodebuild build-for-testing`, then runs it in the smoke test's simulator three times, after `xcrun simctl ui content_size` `large`, `extra-extra-extra-large` and `accessibility-medium`, and sets `large` back. The test hosts each preview listed in `previewScenes()` (the `previews` of a `PreviewProvider`) in a `UIHostingController` in a window of the screen's width, in the light and dark themes without a wallpaper, and draws it with `drawHierarchy` at scale 1, one pixel per point. + +The test runs in UTC (`NSTimeZone.default`) and en_US (`-testLanguage en -testRegion US`), so the renders do not depend on the runner's time zone or locale; a preview that shows the current date or time differs between runs. + +The images go to `snapshots/` of the artifact `ios-sim-smoke-test` as 8-bit sRGB PNG files without alpha (`preferredRange = .standard`, `opaque`), named `--.png`, with `index-.json` listing each file's scene, theme, size, pixel width and height, scale and the content size category the app saw. + +When `apps/ios/SimpleX Snapshots/References` exists, `snapshots.sh` then runs `scripts/ci/snapshots-compare.py `. It compares each render listed in `index-*.json` with the reference of the same name: a render passes when its file is identical to the reference, or when no channel of any pixel differs from it by more than 8 of 255. A missing or extra render, a changed size or a pixel beyond the tolerance fails: the script writes `compare.json` (each file's result and the failed files) next to the renders and, per failed file, `snapshots-diff/` with the reference, the render and the changed pixels in red side by side, lists the failed files in the job summary and exits with 1, which fails the smoke test step. + +`snapshots-remote.py record|check [branch]` renders the snapshots of a pushed branch (the current one by default) on GitHub's runner: it starts `ios-sim.yml` on the branch, waits for the run and downloads `snapshots/` and `snapshots-diff/` of its `ios-sim-smoke-test` to a temporary folder. `record` replaces the PNG files in `apps/ios/SimpleX Snapshots/References` with the run's renders and prints the folder's `git status`, for review and commit; `check` prints the files that failed the run's comparison and the folder of their diff images, and exits with 1 when any failed. It takes the token from `GITHUB_TOKEN` or `GH_TOKEN`, otherwise from `gh auth token`; the token must be allowed to start workflows. + ## The test backend `backend.sh start ` (with the `backend` input) starts, on the runner, against the preset SMP and XFTP servers: diff --git a/scripts/ci/ios-sim/smoke-test.sh b/scripts/ci/ios-sim/smoke-test.sh index e9e245a1ab..9df68585cb 100755 --- a/scripts/ci/ios-sim/smoke-test.sh +++ b/scripts/ci/ios-sim/smoke-test.sh @@ -2,6 +2,8 @@ # Installs the app in a new iPhone simulator and runs the scripted UI cases with the ui helper: onboarding to the # chat list with the one-hand UI (the default), then the chat list again with the top bar. It leaves the app in the one-hand UI. +# For an app in the derived data of a build, it then renders the previews of the SimpleX Snapshots tests to the snapshots +# folder of the output dir (snapshots.sh). # Usage: scripts/ci/ios-sim/smoke-test.sh # Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log. # Later steps of the job get SIM_UDID and UI_OUT, with the simulator still running. @@ -122,3 +124,8 @@ expect_chat_list reachable expect_chat_list top "$ui" layout reachable echo "smoke test passed: onboarding reaches the chat list, which shows its onboarding pages and toolbar with the one-hand UI and with the top bar" + +products=$(dirname "$app") +if [[ "$products" == */Build/Products/Debug-iphonesimulator ]]; then + "$(dirname "$0")/snapshots.sh" "${products%/Build/Products/Debug-iphonesimulator}" "$UI_OUT/snapshots" +fi diff --git a/scripts/ci/ios-sim/snapshots-remote.py b/scripts/ci/ios-sim/snapshots-remote.py new file mode 100755 index 0000000000..a3ae83db29 --- /dev/null +++ b/scripts/ci/ios-sim/snapshots-remote.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +WORKFLOW = "ios-sim.yml" +ARTIFACT = "ios-sim-smoke-test" +REFERENCES = "apps/ios/SimpleX Snapshots/References" + + +def git(*args): + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip() + + +def github_token(): + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + return token + try: + return subprocess.run(["gh", "auth", "token"], check=True, capture_output=True, text=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + sys.exit("set GITHUB_TOKEN, or log in with gh") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + return None + + +class GitHub: + def __init__(self): + self.repo = git("remote", "get-url", "origin").split("github.com", 1)[1].lstrip(":/").removesuffix(".git") + self.token = github_token() + + def request(self, url, body=None): + return urllib.request.Request( + url if url.startswith("https://") else f"https://api.github.com/repos/{self.repo}{url}", + data=None if body is None else json.dumps(body).encode(), + headers={"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json"}, + ) + + def get(self, path): + with urllib.request.urlopen(self.request(path)) as r: + return json.loads(r.read()) + + def post(self, path, body): + urllib.request.urlopen(self.request(path, body)).close() + + def download(self, url): + try: + urllib.request.build_opener(NoRedirect).open(self.request(url)).close() + sys.exit(f"no redirect from {url}") + except urllib.error.HTTPError as e: + if e.code not in (301, 302, 303, 307, 308): + raise + location = e.headers["Location"] + with urllib.request.urlopen(location) as r: + return r.read() + + +def run_workflow(github, branch): + head = git("rev-parse", "HEAD") + quoted = urllib.parse.quote(branch, safe="") + remote_head = github.get(f"/branches/{quoted}")["commit"]["sha"] + if remote_head != head: + sys.exit(f"{branch} is at {remote_head[:9]} on GitHub and at {head[:9]} here: push it first") + dispatched = datetime.now(timezone.utc) - timedelta(minutes=1) + github.post(f"/actions/workflows/{WORKFLOW}/dispatches", {"ref": branch}) + run = None + while run is None: + time.sleep(15) + runs = github.get(f"/actions/workflows/{WORKFLOW}/runs?branch={quoted}&event=workflow_dispatch&per_page=10")["workflow_runs"] + run = next((r for r in runs if r["head_sha"] == head and datetime.fromisoformat(r["created_at"].replace("Z", "+00:00")) >= dispatched), None) + print(f"run {run['html_url']}", flush=True) + while run["status"] != "completed": + time.sleep(60) + run = github.get(f"/actions/runs/{run['id']}") + print(f"run {run['id']}: {run['conclusion']}") + return run + + +def download_snapshots(github, run): + artifacts = github.get(f"/actions/runs/{run['id']}/artifacts?name={ARTIFACT}")["artifacts"] + if not artifacts: + sys.exit(f"run {run['id']} has no artifact {ARTIFACT}") + target = Path(tempfile.mkdtemp(prefix="ios-snapshots-")) + with zipfile.ZipFile(io.BytesIO(github.download(artifacts[0]["archive_download_url"]))) as z: + for name in z.namelist(): + if name.startswith(("snapshots/", "snapshots-diff/")): + z.extract(name, target) + return target + + +def record(downloaded): + renders = downloaded / "snapshots" + files = sorted({e["file"] for index in renders.glob("index-*.json") for e in json.loads(index.read_text())}) + if not files: + sys.exit(f"no snapshots in {renders}") + references = Path(git("rev-parse", "--show-toplevel")) / REFERENCES + references.mkdir(parents=True, exist_ok=True) + for old in references.glob("*.png"): + if old.name not in files: + old.unlink() + for name in files: + shutil.copyfile(renders / name, references / name) + size = sum((references / name).stat().st_size for name in files) + print(f"{len(files)} references, {size / 1e6:.1f} MB, in {references}") + print(git("status", "--short", "--", str(references))) + + +def check(downloaded): + result = downloaded / "snapshots" / "compare.json" + if not result.exists(): + sys.exit(f"the run compared no snapshots; its renders are in {downloaded / 'snapshots'}") + compared = json.loads(result.read_text()) + for name in compared["failed"]: + print(f"{name}: {compared['results'][name]}") + print(f"{len(compared['results'])} snapshots, {len(compared['failed'])} differ; diff images in {downloaded / 'snapshots-diff'}") + sys.exit(1 if compared["failed"] else 0) + + +def main(): + if len(sys.argv) not in (2, 3) or sys.argv[1] not in ("record", "check"): + sys.exit("usage: snapshots-remote.py record|check [branch]") + branch = sys.argv[2] if len(sys.argv) == 3 else git("rev-parse", "--abbrev-ref", "HEAD") + github = GitHub() + downloaded = download_snapshots(github, run_workflow(github, branch)) + if sys.argv[1] == "record": + record(downloaded) + else: + check(downloaded) + + +main() diff --git a/scripts/ci/ios-sim/snapshots.sh b/scripts/ci/ios-sim/snapshots.sh new file mode 100755 index 0000000000..13c1b1a267 --- /dev/null +++ b/scripts/ci/ios-sim/snapshots.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +derived=$1 +out=$2 +mkdir -p "$out" + +xcode=( + -project apps/ios/SimpleX.xcodeproj + -scheme "SimpleX (iOS)" + -configuration Debug + -destination "id=$SIM_UDID" + -derivedDataPath "$derived" + -onlyUsePackageVersionsFromResolvedFile + -skipPackagePluginValidation + -skipMacroValidation + -only-testing:"SimpleX Snapshots" + ARCHS=arm64 + CODE_SIGN_STYLE=Manual + CODE_SIGN_IDENTITY=- + DEVELOPMENT_TEAM= + PROVISIONING_PROFILE_SPECIFIER= +) + +xcodebuild build-for-testing -quiet "${xcode[@]}" + +for size in large extra-extra-extra-large accessibility-medium; do + xcrun simctl ui "$SIM_UDID" content_size "$size" + TEST_RUNNER_SNAPSHOT_DIR="$out" TEST_RUNNER_SNAPSHOT_SIZE="$size" xcodebuild test-without-building -testLanguage en -testRegion US "${xcode[@]}" +done +xcrun simctl ui "$SIM_UDID" content_size large + +ls -l "$out" + +references="apps/ios/SimpleX Snapshots/References" +if [ -d "$references" ]; then + python3 "$(dirname "$0")/../snapshots-compare.py" "$references" "$out" "$(dirname "$out")/snapshots-diff" +fi diff --git a/scripts/ci/snapshots-compare.py b/scripts/ci/snapshots-compare.py new file mode 100755 index 0000000000..f6d99cab14 --- /dev/null +++ b/scripts/ci/snapshots-compare.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 + +import json +import os +import struct +import sys +import zlib +from pathlib import Path + +CHANNEL_TOLERANCE = 8 + + +def unfilter(kind, line, prev, bpp): + if kind == 1: + for i in range(bpp, len(line)): + line[i] = (line[i] + line[i - bpp]) & 0xFF + elif kind == 2: + for i in range(len(line)): + line[i] = (line[i] + prev[i]) & 0xFF + elif kind == 3: + for i in range(len(line)): + left = line[i - bpp] if i >= bpp else 0 + line[i] = (line[i] + ((left + prev[i]) >> 1)) & 0xFF + elif kind == 4: + for i in range(len(line)): + a = line[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 0xFF + + +def read_rgb(path): + data = path.read_bytes() + pos, idat = 8, [] + while pos < len(data): + length, kind = struct.unpack(">I4s", data[pos:pos + 8]) + body = data[pos + 8:pos + 8 + length] + pos += 12 + length + if kind == b"IHDR": + width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + idat.append(body) + if depth not in (8, 16) or color not in (2, 6) or interlace: + raise ValueError(f"{path.name}: bit depth {depth}, colour type {color}, interlace {interlace}") + channels = 3 if color == 2 else 4 + bpp = channels * depth // 8 + stride = width * bpp + raw = zlib.decompress(b"".join(idat)) + rows, prev = [], bytearray(stride) + for y in range(height): + start = y * (stride + 1) + line = bytearray(raw[start + 1:start + 1 + stride]) + unfilter(raw[start], line, prev, bpp) + prev = line + high = line[0::2] if depth == 16 else line + rgb = bytearray(width * 3) + for c in range(3): + rgb[c::3] = high[c::channels] + rows.append(bytes(rgb)) + return width, height, rows + + +def write_rgb(path, width, rows): + def chunk(kind, body): + return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body)) + raw = b"".join(b"\0" + row for row in rows) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, len(rows), 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"") + ) + + +def changed_pixels(ref_rows, out_rows, width): + changed, max_delta = {}, 0 + for y, (a, b) in enumerate(zip(ref_rows, out_rows)): + if a == b: + continue + for x in range(width): + i = x * 3 + delta = max(abs(a[i] - b[i]), abs(a[i + 1] - b[i + 1]), abs(a[i + 2] - b[i + 2])) + max_delta = max(max_delta, delta) + if delta > CHANNEL_TOLERANCE: + changed.setdefault(y, []).append(x) + return changed, max_delta + + +def diff_image(path, ref, out, changed): + (rw, rh, ref_rows), (ow, oh, out_rows) = ref, out + height = max(rh, oh) + grey = bytes([128]) * 3 + rows = [] + for y in range(height): + a = ref_rows[y] if y < rh else grey * rw + b = out_rows[y] if y < oh else grey * ow + row = a + grey * 8 + b + if (rw, rh) == (ow, oh): + mask = bytearray(255 - (255 - v) // 4 for v in a) + for x in changed.get(y, []): + mask[x * 3:x * 3 + 3] = b"\xff\0\0" + row += grey * 8 + mask + rows.append(bytes(row)) + write_rgb(path, len(rows[0]) // 3, rows) + + +def compare(ref_path, out_path, diff_dir): + if ref_path.read_bytes() == out_path.read_bytes(): + return {"status": "equal"} + ref, out = read_rgb(ref_path), read_rgb(out_path) + if ref[:2] != out[:2]: + diff_image(diff_dir / out_path.name, ref, out, {}) + return {"status": "size", "reference": list(ref[:2]), "render": list(out[:2])} + changed, max_delta = changed_pixels(ref[2], out[2], ref[0]) + if not changed: + return {"status": "within tolerance", "maxDelta": max_delta} + diff_image(diff_dir / out_path.name, ref, out, changed) + return {"status": "changed", "changedPixels": sum(len(xs) for xs in changed.values()), "maxDelta": max_delta} + + +def main(): + if len(sys.argv) != 4: + sys.exit("usage: snapshots-compare.py ") + references, renders, diff_dir = map(Path, sys.argv[1:]) + diff_dir.mkdir(parents=True, exist_ok=True) + rendered = {e["file"] for index in sorted(renders.glob("index-*.json")) for e in json.loads(index.read_text())} + expected = {p.name for p in references.glob("*.png")} + results = {} + for name in sorted(expected | rendered): + if name not in rendered: + results[name] = {"status": "missing"} + elif name not in expected: + results[name] = {"status": "extra"} + else: + results[name] = compare(references / name, renders / name, diff_dir) + failed = {n: r for n, r in results.items() if r["status"] not in ("equal", "within tolerance")} + (renders / "compare.json").write_text(json.dumps({"results": results, "failed": sorted(failed)}, indent=2, sort_keys=True)) + lines = [f"{len(results)} snapshots, {len(failed)} differ from the references in {references}"] + lines += [f"- {n}: {', '.join(f'{k} {v}' for k, v in sorted(r.items()))}" for n, r in failed.items()] + print("\n".join(lines)) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a") as f: + f.write("\n".join(["## Snapshots", ""] + lines) + "\n") + sys.exit(1 if failed else 0) + + +main()