| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "os/exec" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func writePortableFixture(t *testing.T, dir, name, content string) { |
| 13 | t.Helper() |
| 14 | if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | func TestVerifyWindowsPortableVersionedLayout(t *testing.T) { |
| 20 | verify := filepath.Join("..", "scripts", "verify-windows-portable.sh") |
| 21 | good := t.TempDir() |
| 22 | // versioned-v1 root entries |
| 23 | writePortableFixture(t, good, "reasonix-launcher.exe", "launcher") |
| 24 | writePortableFixture(t, good, "Reasonix.exe", "launcher") |
| 25 | writePortableFixture(t, good, "reasonix-cli.exe", "cli") |
| 26 | ver := filepath.Join(good, "versions", "v1.20.0") |
| 27 | if err := os.MkdirAll(ver, 0o755); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} { |
| 31 | writePortableFixture(t, ver, name, name) |
| 32 | } |
| 33 | if err := os.WriteFile(filepath.Join(good, "current.json"), []byte(`{ |
| 34 | "schemaVersion": 1, |
| 35 | "activeVersion": "v1.20.0", |
| 36 | "activeDir": "versions/v1.20.0" |
| 37 | } |
| 38 | `), 0o644); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | if out, err := exec.Command("bash", verify, good).CombinedOutput(); err != nil { |
| 42 | t.Fatalf("valid versioned portable failed: %v\n%s", err, out) |
| 43 | } |
| 44 | |
| 45 | // Flat Guard layout must be rejected. |
| 46 | flat := t.TempDir() |
| 47 | for _, name := range []string{ |
| 48 | "reasonix-desktop.exe", |
| 49 | "reasonix-guard.exe", |
| 50 | "reasonix-update-helper.exe", |
| 51 | "reasonix-launcher.exe", |
| 52 | "Reasonix.exe", |
| 53 | "reasonix-cli.exe", |
| 54 | } { |
| 55 | writePortableFixture(t, flat, name, name) |
| 56 | } |
| 57 | if out, err := exec.Command("bash", verify, flat).CombinedOutput(); err == nil { |
| 58 | t.Fatalf("flat portable with guard should fail, output=%s", out) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) { |
| 63 | buildData, err := os.ReadFile("../scripts/desktop-build.sh") |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | build := string(buildData) |
| 68 | for _, want := range []string{ |
| 69 | `CLINAME="reasonix"`, |
| 70 | `WINDOWS_CLINAME="reasonix-cli"`, |
| 71 | `./cmd/reasonix`, |
| 72 | `./cmd/reasonix-legacy-migrator`, |
| 73 | `./cmd/reasonix-launcher`, |
| 74 | `cp "$cli_out" "$app/Contents/MacOS/$CLINAME"`, |
| 75 | `macOS bundle must not include $GUARDNAME`, |
| 76 | `[ "$bundle_executable" = "$BINNAME" ]`, |
| 77 | `Print :CFBundleIconFile`, |
| 78 | `darwin_icon="$ROOT/desktop/build/darwin/icon.icns"`, |
| 79 | `cp "$darwin_icon" "$app/Contents/Resources/$bundle_icon"`, |
| 80 | `cmp -s "$darwin_icon" "$app/Contents/Resources/$bundle_icon"`, |
| 81 | `Contents/Resources/$bundle_icon`, |
| 82 | `-H windowsgui`, |
| 83 | `stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator"`, |
| 84 | `stamp_windows_executable "$launcher_out" "Reasonix Launcher"`, |
| 85 | `stamp_windows_executable "build/windows/installer/$UPDATE_HELPER" "Reasonix Update Helper"`, |
| 86 | `payload_dir="$ROOT/desktop/build/windows/signing-payload"`, |
| 87 | `cp "build/bin/$BINNAME.exe" "$payload_dir/$BINNAME.exe"`, |
| 88 | `cp "$launcher_out" "$payload_dir/$LAUNCHERNAME.exe"`, |
| 89 | `cp "$guard_out" "$payload_dir/$GUARDNAME.exe"`, |
| 90 | `cp "build/windows/installer/$WINDOWS_CLINAME.exe" "$payload_dir/$WINDOWS_CLINAME.exe"`, |
| 91 | `cp "build/windows/installer/reasonix-uninstall.exe" "$payload_dir/reasonix-uninstall.exe"`, |
| 92 | `"$ROOT/scripts/package-windows-desktop.sh" "$arch" "$payload_dir"`, |
| 93 | `"$BINNAME" "$LAUNCHERNAME" "$GUARDNAME" "$CLINAME"`, |
| 94 | `Exec=reasonix-launcher`, |
| 95 | } { |
| 96 | if !strings.Contains(build, want) { |
| 97 | t.Errorf("desktop-build.sh missing packaging contract %q", want) |
| 98 | } |
| 99 | } |
| 100 | if strings.Contains(build, `Set :CFBundleExecutable $GUARDNAME`) { |
| 101 | t.Fatal("macOS package must not replace the native Wails bundle executable with Guard") |
| 102 | } |
| 103 | launcherStamp := strings.Index(build, `stamp_windows_executable "$launcher_out" "Reasonix Launcher"`) |
| 104 | payloadCopy := strings.Index(build, `cp "$launcher_out" "$payload_dir/$LAUNCHERNAME.exe"`) |
| 105 | if launcherStamp < 0 || payloadCopy < 0 || launcherStamp > payloadCopy { |
| 106 | t.Fatalf("Windows payload must copy the already-stamped launcher (stamp=%d copy=%d)", launcherStamp, payloadCopy) |
| 107 | } |
| 108 | if strings.Contains(build, `"$staging/$CLINAME.exe"`) { |
| 109 | t.Fatal("Windows package must not collide reasonix.exe with the Reasonix.exe launcher") |
| 110 | } |
| 111 | darwinIconCopy := strings.Index(build, `cp "$darwin_icon" "$app/Contents/Resources/$bundle_icon"`) |
| 112 | developerIDSign := strings.Index(build, `codesign --force --deep --timestamp --options runtime`) |
| 113 | if darwinIconCopy < 0 || developerIDSign < 0 || darwinIconCopy > developerIDSign { |
| 114 | t.Fatalf("macOS icon must be replaced before signing (icon=%d sign=%d)", darwinIconCopy, developerIDSign) |
| 115 | } |
| 116 | |
| 117 | for _, want := range []string{ |
| 118 | `dpkg-deb --field "$deb_path" Package | grep -x 'reasonix-desktop'`, |
| 119 | `usr/lib/reasonix/reasonix-update-helper`, |
| 120 | `usr/share/polkit-1/actions/io.reasonix.desktop.update.policy`, |
| 121 | } { |
| 122 | if !strings.Contains(build, want) { |
| 123 | t.Errorf("desktop-build.sh missing Linux deb helper contract %q", want) |
| 124 | } |
| 125 | } |
| 126 | for _, unsafe := range []string{ |
| 127 | `dpkg-deb --field "$deb_path" Package | grep -qx`, |
| 128 | `dpkg-deb --field "$deb_path" Version | grep -qx`, |
| 129 | `dpkg-deb --field "$deb_path" Depends | grep -Fq`, |
| 130 | `dpkg-deb --contents "$deb_path" | grep -Eq`, |
| 131 | } { |
| 132 | if strings.Contains(build, unsafe) { |
| 133 | t.Errorf("desktop-build.sh uses early-exit grep under pipefail: %q", unsafe) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | desktopEntry, err := os.ReadFile("build/linux/reasonix.desktop") |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | if !strings.Contains(string(desktopEntry), "Exec=reasonix-launcher") || strings.Contains(string(desktopEntry), "reasonix-guard") { |
| 142 | t.Fatal("Linux desktop entry must launch the permanent launcher without Guard") |
| 143 | } |
| 144 | nfpmData, err := os.ReadFile("build/linux/nfpm.yaml") |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | nfpm := string(nfpmData) |
| 149 | if !strings.Contains(nfpm, "dst: /usr/bin/reasonix-launcher") || strings.Contains(nfpm, "dst: /usr/bin/reasonix-guard") { |
| 150 | t.Fatal("Linux deb must install the permanent launcher and must not persist Guard") |
| 151 | } |
| 152 | if !strings.Contains(nfpm, "postinstall: ./build/linux/postinstall.sh") { |
| 153 | t.Fatal("Linux deb must refresh native desktop icon caches after install and upgrade") |
| 154 | } |
| 155 | if !strings.Contains(nfpm, "dst: /usr/share/applications/reasonix.desktop") { |
| 156 | t.Fatal("Linux deb must install the Reasonix desktop entry") |
| 157 | } |
| 158 | postInstall, err := os.ReadFile("build/linux/postinstall.sh") |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | for _, want := range []string{"gtk-update-icon-cache", "update-desktop-database"} { |
| 163 | if !strings.Contains(string(postInstall), want) { |
| 164 | t.Errorf("Linux post-install icon repair missing %q", want) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | windowsData, err := os.ReadFile("build/windows/installer/project.nsi") |
| 169 | if err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if !bytes.HasPrefix(windowsData, []byte{0xef, 0xbb, 0xbf}) { |
| 173 | t.Fatal("Windows installer script must have a UTF-8 BOM so native makensis accepts localized strings") |
| 174 | } |
| 175 | windows := string(windowsData) |
| 176 | for _, want := range []string{ |
| 177 | `File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`, |
| 178 | `!uninstfinalize 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'`, |
| 179 | `File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"`, |
| 180 | `StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"`, |
| 181 | `File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"`, |
| 182 | `nsExec::ExecToLog /OEM`, |
| 183 | `Reasonix layout activator output:`, |
| 184 | `--activate-staging "$R9" --no-relaunch`, |
| 185 | `CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0`, |
| 186 | `CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0`, |
| 187 | `StrCmp $ReasonixStageMode "1" reasonix_stage_payload`, |
| 188 | `File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"`, |
| 189 | } { |
| 190 | if !strings.Contains(windows, want) { |
| 191 | t.Errorf("Windows installer missing versioned-layout contract %q", want) |
| 192 | } |
| 193 | } |
| 194 | if strings.Contains(windows, `FileOpen $0 "$INSTDIR\current.json" w`) || |
| 195 | strings.Contains(windows, `SetOutPath "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"`) { |
| 196 | t.Fatal("normal Windows installer must not write the live version or current.json in place") |
| 197 | } |
| 198 | if strings.Contains(windows, `ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}"`) { |
| 199 | t.Fatal("Windows installer must not discard layout activator stdout/stderr") |
| 200 | } |
| 201 | if strings.Contains(windows, `CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`) || |
| 202 | strings.Contains(windows, `CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`) { |
| 203 | t.Fatal("Windows shortcut icon must not point into a version directory that retention removes") |
| 204 | } |
| 205 | } |
| 206 |