From 0b4c798e50c34d6c6ea182324f5e16e787cea3bb Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:49:16 +0000 Subject: [PATCH] ci: render iOS previews in the simulator, compare with references (#7905) * ci: render iOS previews in the simulator, compare with references * docs: assets license covers app screenshots and test snapshot images * ci: references of the iOS preview snapshots --- .../References/ASSETS_LICENSE.md | 18 +++ ...hatInfoImage-dark-accessibility-medium.png | Bin 0 -> 3282 bytes ...InfoImage-dark-extra-extra-extra-large.png | Bin 0 -> 3281 bytes .../References/ChatInfoImage-dark-large.png | Bin 0 -> 3298 bytes ...atInfoImage-light-accessibility-medium.png | Bin 0 -> 2538 bytes ...nfoImage-light-extra-extra-extra-large.png | Bin 0 -> 2550 bytes .../References/ChatInfoImage-light-large.png | Bin 0 -> 2500 bytes .../ios/SimpleX Snapshots/SnapshotTests.swift | 104 ++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 116 ++++++++++++++ .../xcschemes/SimpleX (iOS).xcscheme | 10 ++ assets/ASSETS_LICENSE.md | 4 +- scripts/ci/ios-sim/README.md | 18 ++- scripts/ci/ios-sim/smoke-test.sh | 7 + scripts/ci/ios-sim/snapshots-remote.py | 146 +++++++++++++++++ scripts/ci/ios-sim/snapshots.sh | 39 +++++ scripts/ci/snapshots-compare.py | 150 ++++++++++++++++++ 16 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-accessibility-medium.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-extra-extra-extra-large.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-accessibility-medium.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-extra-extra-extra-large.png create mode 100644 apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png create mode 100644 apps/ios/SimpleX Snapshots/SnapshotTests.swift create mode 100755 scripts/ci/ios-sim/snapshots-remote.py create mode 100755 scripts/ci/ios-sim/snapshots.sh create mode 100755 scripts/ci/snapshots-compare.py 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 0000000000000000000000000000000000000000..e742c5dea63990283be55cd6bf660c1b5b276ae9 GIT binary patch literal 3282 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b856951159b8e242f523b6a00ba2e7dbfe64f93b GIT binary patch literal 3281 zcmYLM2|Scr8=o2DHpCcfmLbN@WQ}YwCd&{r2v>|HSwr@TQ1-nTT#f9mkQOS6RAkFK zXc-~b7THP)(Qtjo{kq?G=6BxbnfLdc|FfKDnMt!EnsTxWvqK;dPBVg$4Fm$s0lWbM z2A;68Mtn>0st%X$esPJOaWB<$nt)xls0 zy#2Jk&TZ~tT&1tg!QTzoIy(NmeR~ObGc#jom}2Bi6;HSlPNDq#`O~og$(0LX%Z^owD=RCbQn|Ix z&Z6^cqa{8i(Sp6aetXCB^vs;u0ZfFcxQ@={;9%~Xw-ioxGbN{5o0|)=v%g-n(F11S zVD^a4?(U2J{?q45gE@1Yp{h$SUmAvlJp6<0n+jVtgv|kk<6Z(9ufD}vE*+mw&&qm2 z6E?^Jy4!SNP$=~K_wR@|9xkt2MOl{;-y9JW`}^X}|037HKYaL*oRUK2J5i%gn3*dO zzqwA<&+$8R=8TJr%WfKo7h#$OW@l$-67qFep2JjCL13?JSCsQGe`CV7Vm`h{PGQM_ zro=^suxV*SJ*f4zRtrbVgP_6lfBhx)_-apIAAYC($7nK_kdTn5sHj|>>7g**)FUS4 zo?c!$J6xR;vcUaQZIhERAt5&Tuj6Ht7!wmB!ooinDg<2XX%Ft)Iel1+wJ&L7x4k_+ zD=Vu@dWQ-pv^BH2nI3xJ4b|G+-qqz99ZjxZ^g9elw6>y9VE2tE{XFC`ufs)J#r?sFOa*gi^*`z zo12^G6fDSrn$VTWrqODSDI{{-*0fyh9FMaM`O2eS;O<^qQ`AGJKR&-vqyqpmagL5R zu@7$fqVdoE{`*RS?`oTL21?^i_AzX4{=Z0WnuV;Vx%Yp1roKg;@9yfljcL0xKqL}9 zv^55G)cnjRh&A-=y;iNbDbQy3_k~DJNC@ONmxKQLxp+mfHfo7!rv*svrG+YHC!;sR2jhu)A4WdUbQz&f41A#H4$V z`6DzWWO-$!x~d9DTD;@d(bd(>;9K5Zqb{*QC4v;t2!MiS$;N zmtVRo)Ejx=6)`qX5k{&CnRS?!bRqijbtYB3)$Wzftq~zEj@G%7ICoce>v9fkDC4QQ zeS2h9wn7qK=o44Q+}hgu_U+riz(8jw{N-1E%l%Sz#6z!J{TMy+X$?NekzfgnwmN~I)vb_ zo11opY7b}U=0bmban7uI_s;I7n#(g(OK-1P*l^GFkD3P_pPruSNO`Z{-6QZs!a>>f zg;a-&F4D&2PHinpSu)CWPR!uS-RG{ph6n_-P!{KQ#owR9X#qXoE`z~#IStRu%v2SB z9V|Q%c>)o-7X2t=Q9o_Fq@<+kv$G>F4BuwYHvOe|PFBHiqncDYJ~2^vKKYq@acOC5 zV`GwLv2?M6M`UE=!lLsy9}t#dD>H@nlfoIpSU9V*`j5_>+}zR8QIee!$MX|`f00`# zgCEXkZzaW|>ZM`j<>iS5L=RcHj7)edR-Mkm(;7SrRBpY-hr{O{GkE0do--9CT5W%?&Tcqc^q6I zEdgg`H4GwTrTEUts4GHVR#CAZjg*XHf7n`9ZsqP>e)h@5Nwrt2m6qVjHq6Au#ba+= z;g};=%AmqAbD%yMwxKU#n3PMGP8>U9n`Wf6&e=s*bbY<~(c{OOl;pCQcdAmO&_TS=-tMjvw@*PW@K#1D_H+-j=2?o* z;>&V1G)5J)7+DQyMsLq(VPu9J22;_{urb+k$y~D5?c4?K0Xz!KIpw>_GDS_trQwOO z0lcZ1nP_2Qd@aWrUMn%@bx z&}&afj!#PIF>A;y3@FUYbODLCA`$EKV{t_CqZzlT2GW=niob-?P?CRLWuAixSeCwc zMp^!tTzq4Fy_JK*_V2Dw9*e1|@qkF(y$Kn)A0H2!o!1IWrJfsDNz9fYnXBs6F@jYh zi&VyLD7%7LBBkD6yB{u8gc^oI2Y^qTf0|oe$wW*umAk=YTSp@2m6PZh_y0;T;jSY?1BEKdol6@dQ6l&-u?2Pr}2) zJ$$L;3+lOfaR(jgG|V48G#ch0{VenoQY4$fV0@c#V8_^z$pbHtp#Me0`$O*aFZEYq bEv8*H)b5|9Ax94WJV4BhiAMMFZt?#CSr4G3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9d4f92bbacd167ab2c54af5e5a7d2af21cd3ab96 GIT binary patch literal 3298 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a62931eb3514c14e12a79bb1f9a332772aeff63 GIT binary patch literal 2500 zcmYLJ2|QH$7r!%1%nXffYy+sKk_8tX_#_N55L*v1lNdyiu5YZ{E5nQV#i zGV(}5Pf=lbQt`@AJRa|QpWgrfyPtEv=id80=l;&`d_U)|z3l~Fgg62K0A5QAGb{i= zazXz*Hx#^M(65z2fP`Z&m;g=BC04Y0r`K%3g~}qNNxqke?D;jFbpPP`4I$o zsD*1d03Zbq4FVLEh=MOA0?h1iARZQRxTHZ-`hijaz^S8~NW2Xqr=^)OE(-Fw06{vQmbDG;vt8;stlfNLi@e5?5 zbim&COmSq-Tbx^-Yra9{?3lBd>aFl3QxD?Hhp@p|YPu zg##RWx$etP-0Znb^HJe#CbgjB;W#+Z-uC-#Oi}NKI zy5qeDVh<1?}<*N-?w8 zkfYrnN)Me3{uaI0^PphLx6ExCY%}!_!OaNvwtc3WH`}|&!odgMjlFxfF8EGdx6;Ro zcVPThS-B$qLO*6EZzOwk+G{X{smE3fJJ-1>+CblOFq)|!ckdk#pclMxS98<7)igOb zN28bZ5gMoJtO-=x_OCbaQJQ|fY3!P^9qF9%0u8e3PlhfK!l!|x{U&B1%62gOtk-X1 z<@-&#xilZ}SlSdEtj-uOp-Z;m+NwB{eTphI7tPYaGOCD$yKUf&#I zwW=A`+_L3fm2(dhY3h4~j$Yr{XxmAV{`{pR<8zjh*>-#IOH`6cw~UQBlF@S)b*7_ryW60%>>g4XgLDZkHj+MpL{{+#ci(+V@Z zqj~rArCkNJWw`C_M%+NL^MM9977h;jVBar;^Puu@9;}swT>p5I$Nc+2!pgcQ&(P32 z67RYJ_93^AQTr>K(m9G0UkH}jN|nBHep&%wshju0yY&Bu!=0)D@QQ!838N|YP ztYJc2oB>kf2r(w6=gE^tUcw!J@9ljqC@Y$!fjOJ5_xPIoK5{wHU@tHa<8r1>`?e=u zlF-o5FmvI{a^T@I@r-wS<8y@eyC0a^uv{!KxPbPwR*moD=Gilad&XM2b(Q?`^?FEd z{B+kSmQW=#C_S8FRcd0ty|eTBB_h|c7;N^NbH4S>&wRZU{e$&MY9Vq(hOjNTApIk| zuyUw@uN8YO1O31_KdJ!fx09(IZz^YjixlRg*M}FjG6s=#vXwLQ$;u7nWE0_C-R(hT zk%08KsU#km2BuiC(PAKYG>*m0hOM&x!;G+-_F$zqS8hg0nO zV3dBL+mCvb(=7P;2XC@Ll0H7<_*gP5MGfUq;E`5B3YxM=6G^_qYVxiN%`%+CTZ!wm zkXn}e;fAG_&A zJ*;islqm`2;)|t52B>h`^=Xb*;Lc_0eK_tD@wN-BZRKc@`Q!GbD)svs2U(STUiYdd z`%kdNTQ%>KM>oKJj?DIE^UFBrs$p?xhPn#6;g9}(PXX`S4&2{`{9MJl40IVzF^1a3 z{25jafr<5#DlbAuv~-^$9*gvL=FIX7Naqc&=)E{yoXV+vC`Mhfo86ss&cM3oj0}j=rhDFFuDbH6bIXB?*dYp7OL_d)TJ)xjRgw-o!X#a!Deb-dYK9HvcUFSi zfLLA=qU{{-}+IMWBLhHU&;n|sNlpT%g$3wLLNRgs`=OrL(HRt(tNYXn<*kZ z#k#c*CEXZC6F028ICSNj^;8&v^U+G@VFYUL*sNHN(p_F-t6Akd=?SFj_Z%BafXs#!|m [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()