mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
|
|
const windows = test.skipIf(process.platform !== "win32")
|
|
|
|
// Matches the 16 hex characters the staged directory name takes from APP_64_HASH.
|
|
const payloadHash = "0123456789ABCDEF0123456789ABCDEF"
|
|
|
|
// The installer clears the uninstall entry of an install it moves; the tests point it at their own key.
|
|
const uninstallKey = "Software\\OpenCodeInstallerTest"
|
|
|
|
async function harness() {
|
|
const { getMakeNsisPath, getNsisPluginsPath } = await import("app-builder-lib/out/toolsets/windows")
|
|
const { nsisTemplatesDir } = await import("app-builder-lib/out/targets/nsis/nsisUtil")
|
|
const { NsisScriptGenerator } = await import("app-builder-lib/out/targets/nsis/nsisScriptGenerator")
|
|
const config = (await import("../electron-builder.config")).default
|
|
const compiler = await getMakeNsisPath(config.toolsets?.nsis, config.nsis?.customNsisBinary)
|
|
const plugins = await getNsisPluginsPath(config.toolsets?.nsis, config.nsis?.customNsisResources)
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "opencode-installer-"))
|
|
// electron-builder generates the command-line flags such as ${isUpdated}.
|
|
const flags = new NsisScriptGenerator()
|
|
flags.flags(["updated"])
|
|
|
|
const run = async (cmd: string[], env: Record<string, string> = {}) => {
|
|
const result = Bun.spawn(cmd, { env: { ...process.env, ...compiler.env, ...env }, stdout: "pipe", stderr: "pipe" })
|
|
|
|
const [stdout, stderr, code] = await Promise.all([
|
|
new Response(result.stdout).text(),
|
|
new Response(result.stderr).text(),
|
|
result.exited,
|
|
])
|
|
|
|
return { stdout: stdout.trim(), stderr, code }
|
|
}
|
|
|
|
// Compiles the custom include with the template's flags and messages around one section body.
|
|
const compile = async (name: string, section: string) => {
|
|
const script = path.join(dir, `${name}.nsi`)
|
|
const exe = path.join(dir, `${name}.exe`)
|
|
await Bun.write(
|
|
script,
|
|
`Unicode true
|
|
RequestExecutionLevel user
|
|
SilentInstall silent
|
|
OutFile "${exe}"
|
|
!addplugindir /x86-unicode "${path.join(plugins, "x86-unicode")}"
|
|
!addincludedir "${path.join(nsisTemplatesDir, "include")}"
|
|
!include LogicLib.nsh
|
|
!include StdUtils.nsh
|
|
${flags.build()}
|
|
LoadLanguageFile "\${NSISDIR}\\Contrib\\Language files\\English.nlf"
|
|
LangString appRunning \${LANG_ENGLISH} "running"
|
|
LangString appClosing \${LANG_ENGLISH} "closing"
|
|
LangString appCannotBeClosed \${LANG_ENGLISH} "cannot close"
|
|
!define APP_EXECUTABLE_FILENAME "OpenCode.exe"
|
|
!define APP_ID "ai.opencode.desktop"
|
|
!define APP_FILENAME "@opencodedesktop"
|
|
!define UNINSTALL_FILENAME "Uninstall OpenCode.exe"
|
|
!define UNINSTALL_REGISTRY_KEY "${uninstallKey}"
|
|
!define SHORTCUT_NAME "OpenCode Installer Test"
|
|
!define APP_64 "${path.join(dir, "app.7z")}"
|
|
!define APP_64_HASH "${payloadHash}"
|
|
!define APP_64_UNPACKED_SIZE 1
|
|
!include "${config.nsis?.include}"
|
|
Section
|
|
${section}
|
|
SectionEnd
|
|
`,
|
|
)
|
|
const result = await run([compiler.path, script])
|
|
expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0)
|
|
|
|
return exe
|
|
}
|
|
|
|
return { dir, run, compile, cleanup: () => rm(dir, { recursive: true, force: true }) }
|
|
}
|
|
|
|
windows(
|
|
"installer grants sandbox access without changing unrelated permissions",
|
|
async () => {
|
|
const installer = await harness()
|
|
|
|
const succeed = async (cmd: string[]) => {
|
|
const result = await installer.run(cmd, { OPENCODE_INSTALLER_TEST_ROOT: installer.dir })
|
|
expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0)
|
|
|
|
return result.stdout
|
|
}
|
|
|
|
const acl = (file: string) =>
|
|
succeed([
|
|
"pwsh",
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
`(Get-Acl -LiteralPath (Join-Path $env:OPENCODE_INSTALLER_TEST_ROOT '${file}')).Sddl`,
|
|
])
|
|
|
|
try {
|
|
await Bun.write(path.join(installer.dir, "app/runtime/icudtl.dat"), "runtime fixture")
|
|
await Bun.write(path.join(installer.dir, "unrelated.txt"), "outside the installation")
|
|
await succeed(["icacls.exe", path.join(installer.dir, "app"), "/grant", "*S-1-15-2-999-999-999:(OI)(CI)(F)"])
|
|
const parent = await acl(".")
|
|
const unrelated = await acl("unrelated.txt")
|
|
|
|
const exe = await installer.compile(
|
|
"acl",
|
|
` StrCpy $INSTDIR "$EXEDIR\\app"
|
|
!insertmacro customInstall`,
|
|
)
|
|
|
|
await succeed([exe])
|
|
const installed = await acl("app/runtime/icudtl.dat")
|
|
expect(installed).toContain("(A;ID;0x1200a9;;;S-1-15-2-2)")
|
|
expect(installed).toContain("S-1-15-2-999-999-999")
|
|
await succeed([exe])
|
|
expect(await acl("app/runtime/icudtl.dat")).toBe(installed)
|
|
expect(await acl(".")).toBe(parent)
|
|
expect(await acl("unrelated.txt")).toBe(unrelated)
|
|
} finally {
|
|
await installer.cleanup()
|
|
}
|
|
},
|
|
120_000,
|
|
)
|
|
|
|
// The running app unpacks an update beside the install directory before the restart. The install takes it
|
|
// only when it belongs to this payload and the old version is gone, and never leaves a staged copy behind.
|
|
const stagedCases = [
|
|
{
|
|
name: "moves the staged app into the emptied install directory",
|
|
installed: [],
|
|
staged: { [`update-${payloadHash.slice(0, 16)}`]: ["OpenCode.exe", "resources/app.asar"] },
|
|
moved: true,
|
|
expected: ["OpenCode.exe", "resources/app.asar"],
|
|
},
|
|
{
|
|
name: "keeps an install directory that still holds files",
|
|
installed: ["locked.dll"],
|
|
staged: { [`update-${payloadHash.slice(0, 16)}`]: ["OpenCode.exe"] },
|
|
moved: false,
|
|
expected: ["locked.dll"],
|
|
},
|
|
{
|
|
name: "discards an update staged from another payload",
|
|
installed: [],
|
|
staged: { "update-FEDCBA9876543210": ["OpenCode.exe"] },
|
|
moved: false,
|
|
expected: [],
|
|
},
|
|
{
|
|
name: "discards staging that never finished",
|
|
installed: [],
|
|
staged: { staging: ["OpenCode.exe"] },
|
|
moved: false,
|
|
expected: [],
|
|
},
|
|
]
|
|
|
|
windows(
|
|
"installs a staged update only into an emptied install directory",
|
|
async () => {
|
|
const installer = await harness()
|
|
|
|
try {
|
|
const exe = await installer.compile(
|
|
"staged",
|
|
` InitPluginsDir
|
|
SetOutPath $INSTDIR
|
|
!insertmacro customInstallStagedApp
|
|
\${If} $R0 == "installed"
|
|
SetErrorLevel 0
|
|
\${Else}
|
|
SetErrorLevel 3
|
|
\${EndIf}`,
|
|
)
|
|
|
|
for (const item of stagedCases) {
|
|
const root = path.join(installer.dir, item.name.replaceAll(" ", "-"))
|
|
const install = path.join(root, "app")
|
|
await mkdir(install, { recursive: true })
|
|
await Promise.all([
|
|
...item.installed.map((file) => Bun.write(path.join(install, file), "old")),
|
|
...Object.entries(item.staged).flatMap(([suffix, files]) =>
|
|
files.map((file) => Bun.write(path.join(`${install}.${suffix}`, file), "new")),
|
|
),
|
|
])
|
|
|
|
const result = await installer.run([exe, `/D=${install}`])
|
|
const files = await readdir(install, { recursive: true, withFileTypes: true })
|
|
|
|
expect(result.code, item.name).toBe(item.moved ? 0 : 3)
|
|
expect(
|
|
files
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) => path.relative(install, path.join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
.toSorted(),
|
|
item.name,
|
|
).toEqual(item.expected)
|
|
expect(await readdir(root), item.name).toEqual(["app"])
|
|
}
|
|
} finally {
|
|
await installer.cleanup()
|
|
}
|
|
},
|
|
120_000,
|
|
)
|
|
|
|
// Every channel used to install into one directory named after the package. Each channel now installs into
|
|
// a directory named after its app ID and leaves the shared one without touching the other channels' files.
|
|
const directoryCases = [
|
|
{
|
|
name: "a first install uses the app ID",
|
|
from: "@opencodedesktop",
|
|
shared: [],
|
|
expected: "ai.opencode.desktop",
|
|
left: undefined,
|
|
uninstallKept: true,
|
|
},
|
|
{
|
|
name: "leaving the shared directory keeps the other channels",
|
|
from: "@opencodedesktop",
|
|
shared: [
|
|
"OpenCode.exe",
|
|
"Uninstall OpenCode.exe",
|
|
"OpenCode Beta.exe",
|
|
"Uninstall OpenCode Beta.exe",
|
|
"resources/app.asar",
|
|
],
|
|
expected: "ai.opencode.desktop",
|
|
left: ["OpenCode Beta.exe", "Uninstall OpenCode Beta.exe", "resources/app.asar"],
|
|
uninstallKept: false,
|
|
},
|
|
{
|
|
name: "the last channel removes the shared directory",
|
|
from: "@opencodedesktop",
|
|
shared: ["OpenCode.exe", "Uninstall OpenCode.exe", "resources/app.asar"],
|
|
expected: "ai.opencode.desktop",
|
|
left: undefined,
|
|
uninstallKept: false,
|
|
},
|
|
{
|
|
name: "an install elsewhere stays where it is",
|
|
from: "custom",
|
|
shared: ["OpenCode.exe", "Uninstall OpenCode.exe"],
|
|
expected: "custom",
|
|
left: ["OpenCode.exe", "Uninstall OpenCode.exe"],
|
|
uninstallKept: true,
|
|
},
|
|
]
|
|
|
|
windows(
|
|
"installs each channel into a directory named after its app ID",
|
|
async () => {
|
|
const installer = await harness()
|
|
const registry = (args: string[]) => installer.run(["reg.exe", ...args, "/f"])
|
|
|
|
try {
|
|
const exe = await installer.compile(
|
|
"directory",
|
|
` !insertmacro customInit
|
|
FileOpen $0 "$EXEDIR\\instdir.txt" w
|
|
FileWrite $0 $INSTDIR
|
|
FileClose $0
|
|
!insertmacro leaveSharedInstall`,
|
|
)
|
|
|
|
for (const item of directoryCases) {
|
|
const root = path.join(installer.dir, item.name.replaceAll(" ", "-"))
|
|
const from = path.join(root, item.from)
|
|
await mkdir(root, { recursive: true })
|
|
await Promise.all(item.shared.map((file) => Bun.write(path.join(from, file), "old")))
|
|
await registry(["add", `HKCU\\${uninstallKey}`, "/v", "UninstallString", "/d", "uninstall"])
|
|
|
|
expect((await installer.run([exe, `/D=${from}`])).code, item.name).toBe(0)
|
|
expect(await Bun.file(path.join(installer.dir, "instdir.txt")).text(), item.name).toBe(
|
|
path.join(root, item.expected),
|
|
)
|
|
const left = (await readdir(from, { recursive: true, withFileTypes: true }).catch(() => undefined))
|
|
?.filter((entry) => entry.isFile())
|
|
.map((entry) => path.relative(from, path.join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
.toSorted()
|
|
expect(left, item.name).toEqual(item.left)
|
|
const query = await installer.run(["reg.exe", "query", `HKCU\\${uninstallKey}`, "/v", "UninstallString"])
|
|
expect(query.code === 0, item.name).toBe(item.uninstallKept)
|
|
}
|
|
} finally {
|
|
await installer.run(["reg.exe", "delete", `HKCU\\${uninstallKey}`, "/f"])
|
|
await installer.cleanup()
|
|
}
|
|
},
|
|
120_000,
|
|
)
|
|
|
|
// Stand-ins for the app are copies of ping.exe: it exits with 0 when it finishes and 1 when terminated.
|
|
const runningCases = [
|
|
{ name: "a manual install stops the app at once", args: [], pings: 30, exitCode: 1 },
|
|
{ name: "an update lets the quitting app exit by itself", args: ["--updated"], pings: 2, exitCode: 0 },
|
|
]
|
|
|
|
windows(
|
|
"stops only the processes that run from the install directory",
|
|
async () => {
|
|
const installer = await harness()
|
|
const ping = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "PING.EXE")
|
|
|
|
try {
|
|
const exe = await installer.compile("running", " !insertmacro customCheckAppRunning")
|
|
|
|
for (const item of runningCases) {
|
|
const root = path.join(installer.dir, item.name.replaceAll(" ", "-"))
|
|
const install = path.join(root, "app")
|
|
// Shares the install directory's name as a prefix, so a match without the separator would stop it.
|
|
const sibling = path.join(root, "app-sibling")
|
|
await mkdir(path.join(install, "resources"), { recursive: true })
|
|
await mkdir(sibling, { recursive: true })
|
|
await copyFile(ping, path.join(install, "resources", "standin.exe"))
|
|
await copyFile(ping, path.join(sibling, "standin.exe"))
|
|
|
|
const app = Bun.spawn([path.join(install, "resources", "standin.exe"), "-n", String(item.pings), "127.0.0.1"], {
|
|
stdout: "ignore",
|
|
})
|
|
|
|
const other = Bun.spawn([path.join(sibling, "standin.exe"), "-n", "30", "127.0.0.1"], { stdout: "ignore" })
|
|
|
|
try {
|
|
expect((await installer.run([exe, ...item.args, `/D=${install}`])).code, item.name).toBe(0)
|
|
expect(other.exitCode, item.name).toBeNull()
|
|
expect(await app.exited, item.name).toBe(item.exitCode)
|
|
} finally {
|
|
app.kill()
|
|
other.kill()
|
|
await Promise.all([app.exited, other.exited])
|
|
}
|
|
}
|
|
} finally {
|
|
await installer.cleanup()
|
|
}
|
|
},
|
|
120_000,
|
|
)
|