Ctrl K
ComunidadGuías…

packages/desktop/script/windows-installer.test.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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,
)