| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestInstallerCommandLineUsesVisibleUpdateModeAndKeepsDFlagLast(t *testing.T) { |
| 10 | got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`) |
| 11 | want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App` |
| 12 | if got != want { |
| 13 | t.Fatalf("installerCommandLine = %q, want %q", got, want) |
| 14 | } |
| 15 | if strings.Contains(got, " /S") { |
| 16 | t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got) |
| 17 | } |
| 18 | if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) { |
| 19 | t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | func TestWindowsUpdateHandoffArgsCarryParentInstallAndRelaunch(t *testing.T) { |
| 24 | got := windowsUpdateHandoffArgs( |
| 25 | 4242, |
| 26 | `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`, |
| 27 | strings.Repeat("a", 64), |
| 28 | `D:\Tools\Reasonix App`, |
| 29 | `D:\Tools\Reasonix App\reasonix-desktop.exe`, |
| 30 | "v1.6.0", |
| 31 | "2026-07-29T00:00:00Z", |
| 32 | "transaction-1", |
| 33 | ) |
| 34 | want := []string{ |
| 35 | "--parent-pid", "4242", |
| 36 | "--installer", `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`, |
| 37 | "--installer-sha256", strings.Repeat("a", 64), |
| 38 | "--to-version", "v1.6.0", |
| 39 | "--created-at", "2026-07-29T00:00:00Z", |
| 40 | "--transaction-id", "transaction-1", |
| 41 | "--install-dir", `D:\Tools\Reasonix App`, |
| 42 | "--relaunch", `D:\Tools\Reasonix App\reasonix-desktop.exe`, |
| 43 | } |
| 44 | if strings.Join(got, "\x00") != strings.Join(want, "\x00") { |
| 45 | t.Fatalf("args = %#v, want %#v", got, want) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestWindowsVersionedUpdateHandoffArgsDoNotRequireLegacyPendingIdentity(t *testing.T) { |
| 50 | got := windowsVersionedUpdateHandoffArgs( |
| 51 | 4242, |
| 52 | `C:\Temp\Reasonix-installer.exe`, |
| 53 | strings.Repeat("b", 64), |
| 54 | `D:\Tools\Reasonix`, |
| 55 | `D:\Tools\Reasonix\reasonix-launcher.exe`, |
| 56 | "v1.20.0", |
| 57 | ) |
| 58 | want := []string{ |
| 59 | "--parent-pid", "4242", |
| 60 | "--installer", `C:\Temp\Reasonix-installer.exe`, |
| 61 | "--installer-sha256", strings.Repeat("b", 64), |
| 62 | "--to-version", "v1.20.0", |
| 63 | "--install-layout", "versioned-v1", |
| 64 | "--install-dir", `D:\Tools\Reasonix`, |
| 65 | "--relaunch", `D:\Tools\Reasonix\reasonix-launcher.exe`, |
| 66 | } |
| 67 | if strings.Join(got, "\x00") != strings.Join(want, "\x00") { |
| 68 | t.Fatalf("args = %#v, want %#v", got, want) |
| 69 | } |
| 70 | for _, legacy := range []string{"--created-at", "--transaction-id"} { |
| 71 | if strings.Contains(strings.Join(got, " "), legacy) { |
| 72 | t.Fatalf("versioned handoff must not carry legacy field %s", legacy) |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) { |
| 78 | data, err := os.ReadFile("build/windows/installer/project.nsi") |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | script := string(data) |
| 83 | for _, want := range []string{ |
| 84 | `!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"`, |
| 85 | `!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"`, |
| 86 | `!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"`, |
| 87 | `!define REASONIX_GUARD "reasonix-guard.exe"`, |
| 88 | `!define REASONIX_LAUNCHER "reasonix-launcher.exe"`, |
| 89 | `!define REASONIX_CLI "reasonix-cli.exe"`, |
| 90 | `!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"`, |
| 91 | `!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"`, |
| 92 | `!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"`, |
| 93 | `!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"`, |
| 94 | "Var ReasonixUpdateMode", |
| 95 | "Var ReasonixStageMode", |
| 96 | `${GetOptions} $R0 "/REASONIXUPDATE=" $R1`, |
| 97 | `${GetOptions} $R0 "/REASONIXSTAGE=" $R2`, |
| 98 | "Function reasonix.skipSetupPageForUpdate", |
| 99 | "Function reasonix.showUpdateProgress", |
| 100 | `!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipFinishPageForUpdate`, |
| 101 | "Function reasonix.skipFinishPageForUpdate", |
| 102 | `StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_finish_page`, |
| 103 | "SetAutoClose true", |
| 104 | "BringToFront", |
| 105 | `LangString reasonixUpdateTitle ${LANG_ENGLISH} "Updating Reasonix"`, |
| 106 | `LangString reasonixUpdateTitle ${LANG_SIMPCHINESE} "正在更新 Reasonix"`, |
| 107 | `LangString reasonixUpdateTitle ${LANG_TRADCHINESE} "正在更新 Reasonix"`, |
| 108 | `LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified update. Reasonix will restart automatically."`, |
| 109 | `LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"`, |
| 110 | `LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"`, |
| 111 | "Function reasonix.waitForExecutableUnlock", |
| 112 | `FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a`, |
| 113 | `FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a`, |
| 114 | `FileOpen $1 "$INSTDIR\${REASONIX_GUARD}" a`, |
| 115 | `FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a`, |
| 116 | `FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a`, |
| 117 | `FileOpen $1 "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" a`, |
| 118 | "SetErrorLevel 1618", |
| 119 | "Call reasonix.waitForExecutableUnlock", |
| 120 | `File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"`, |
| 121 | `File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`, |
| 122 | `File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"`, |
| 123 | `nsExec::ExecToLog /OEM`, |
| 124 | `Reasonix layout activator output:`, |
| 125 | `--activate-staging "$R9" --no-relaunch`, |
| 126 | `File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"`, |
| 127 | `File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"`, |
| 128 | `Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"`, |
| 129 | `Delete "$INSTDIR\${REASONIX_CLI}"`, |
| 130 | `DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""`, |
| 131 | `!insertmacro reasonix.deleteLegacyInstallerStateIfOwned`, |
| 132 | } { |
| 133 | if !strings.Contains(script, want) { |
| 134 | t.Fatalf("project.nsi missing %q", want) |
| 135 | } |
| 136 | } |
| 137 | finishPageHook := strings.Index(script, "!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipFinishPageForUpdate") |
| 138 | finishPage := strings.Index(script, "!insertmacro MUI_PAGE_FINISH") |
| 139 | if finishPageHook < 0 || finishPage < 0 || finishPageHook > finishPage { |
| 140 | t.Fatalf("update-only finish page hook must be attached to MUI_PAGE_FINISH (hook=%d page=%d)", finishPageHook, finishPage) |
| 141 | } |
| 142 | wait := strings.Index(script, "Call reasonix.waitForExecutableUnlock") |
| 143 | copyFiles := strings.Index(script, "!insertmacro wails.files") |
| 144 | if wait < 0 || copyFiles < 0 || wait > copyFiles { |
| 145 | t.Fatalf("installer must wait for the running exe to unlock before wails.files (wait=%d copy=%d)", wait, copyFiles) |
| 146 | } |
| 147 | stageBranch := strings.Index(script, "StrCmp $ReasonixStageMode \"1\" reasonix_stage_payload") |
| 148 | if stageBranch < 0 || stageBranch > copyFiles { |
| 149 | t.Fatalf("staging mode must bypass live executable unlock before payload extraction (branch=%d copy=%d)", stageBranch, copyFiles) |
| 150 | } |
| 151 | if !strings.Contains(script, "Goto reasonix_section_done") { |
| 152 | t.Fatal("staging mode must skip registry, shortcuts, associations, and uninstaller") |
| 153 | } |
| 154 | if strings.Contains(script, `FileOpen $0 "$INSTDIR\current.json" w`) { |
| 155 | t.Fatal("normal installer must delegate the current.json commit to the atomic Go activator") |
| 156 | } |
| 157 | writeCurrent := strings.Index(script, `!insertmacro reasonix.writeUninstaller`) |
| 158 | deleteLegacy := strings.Index(script, `!insertmacro reasonix.deleteLegacyInstallerStateIfOwned`) |
| 159 | if writeCurrent < 0 || deleteLegacy < 0 || writeCurrent > deleteLegacy { |
| 160 | t.Fatalf("installer must write the current uninstall entry before reconciling owned legacy state (write=%d delete=%d)", writeCurrent, deleteLegacy) |
| 161 | } |
| 162 | legacyMacro := script[strings.Index(script, `!macro reasonix.deleteLegacyInstallerStateIfOwned`):strings.Index(script, `!macro reasonix.deleteUninstaller`)] |
| 163 | deleteLegacyLocation := strings.Index(legacyMacro, `DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""`) |
| 164 | deleteLegacyAlias := strings.Index(legacyMacro, `DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"`) |
| 165 | if deleteLegacyLocation < 0 || deleteLegacyAlias < 0 || deleteLegacyLocation > deleteLegacyAlias { |
| 166 | t.Fatalf("installer must clear the same-root Tauri install-location breadcrumb before deleting its uninstall alias (location=%d alias=%d)", deleteLegacyLocation, deleteLegacyAlias) |
| 167 | } |
| 168 | metadataBranch := strings.Index(script, `reasonix_stage_payload:`) |
| 169 | metadataFile := strings.Index(script, `File "/oname=${REASONIX_PAYLOAD_MANIFEST}"`) |
| 170 | normalInstall := strings.Index(script, `reasonix_normal_install:`) |
| 171 | if metadataBranch < 0 || metadataFile < 0 || normalInstall < 0 || metadataBranch > metadataFile || metadataFile > normalInstall { |
| 172 | t.Fatalf("payload manifest must be extracted only in staging mode (branch=%d file=%d)", metadataBranch, metadataFile) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestDesktopBuildScriptCompilesAndPackagesWindowsUpdateHelper(t *testing.T) { |
| 177 | data, err := os.ReadFile("../scripts/desktop-build.sh") |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | script := string(data) |
| 182 | for _, want := range []string{ |
| 183 | `UPDATE_HELPER="reasonix-update-helper.exe"`, |
| 184 | `go build -trimpath -o "$windows_resource_tool" ./cmd/windows-resource`, |
| 185 | `GOOS=windows GOARCH="$arch" go build`, |
| 186 | `./cmd/update-helper`, |
| 187 | `build/windows/installer/$UPDATE_HELPER`, |
| 188 | `stamp_windows_executable "build/windows/installer/$UPDATE_HELPER"`, |
| 189 | `cp "build/windows/installer/$UPDATE_HELPER" "$payload_dir/$UPDATE_HELPER"`, |
| 190 | } { |
| 191 | if !strings.Contains(script, want) { |
| 192 | t.Fatalf("desktop-build.sh missing %q", want) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | packageData, err := os.ReadFile("../scripts/package-windows-desktop.sh") |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | packager := string(packageData) |
| 201 | for _, want := range []string{ |
| 202 | `cp "$PAYLOAD/$UPDATE_HELPER" "$INSTALLER_DIR/$UPDATE_HELPER"`, |
| 203 | `cp "$PAYLOAD/$UPDATE_HELPER" "$portable_staging/versions/$version_label/$UPDATE_HELPER"`, |
| 204 | `"$ROOT/scripts/verify-windows-portable.sh" "$portable_staging"`, |
| 205 | } { |
| 206 | if !strings.Contains(packager, want) { |
| 207 | t.Fatalf("package-windows-desktop.sh missing %q", want) |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestWindowsUpdateRequiresObservedHelperHandoff(t *testing.T) { |
| 213 | data, err := os.ReadFile("updater_windows.go") |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | source := string(data) |
| 218 | if !strings.Contains(source, "return startWindowsUpdateHelper(") { |
| 219 | t.Fatal("Windows update handoff does not require the observed helper path") |
| 220 | } |
| 221 | if strings.Contains(source, "return installerCommand(installerPath, installDir).Start()") { |
| 222 | t.Fatal("Windows update silently falls back to an unobserved installer") |
| 223 | } |
| 224 | if !strings.Contains(source, "cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}") { |
| 225 | t.Fatal("Windows handoff helper should stay hidden while NSIS shows update progress") |
| 226 | } |
| 227 | helperData, err := os.ReadFile("cmd/update-helper/main_windows.go") |
| 228 | if err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | helperSource := string(helperData) |
| 232 | if !strings.Contains(helperSource, "reconcileWindowsUninstallRegistrationFn(installDir, toVersion)") { |
| 233 | t.Fatal("versioned Windows activation must refresh its managed uninstall registration") |
| 234 | } |
| 235 | if strings.Contains(helperSource, "installerCommandLine(installer, installDir), HideWindow: true") { |
| 236 | t.Fatal("update helper still hides the NSIS progress window") |
| 237 | } |
| 238 | } |
| 239 |