OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / pear-v3-app-launcher.test.js

209 lines · javascript ref:
import test from 'node:test'
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import launcherMod from '../electron/pear-app-launcher.cjs'

const { PearAppLauncher, normalizePearInstallLink, validateInstallEvent, validateResolvedTargets } = launcherMod
const KEY = 'a'.repeat(52)
const LINK = `pear://${KEY}`

function installEvent (overrides = {}) {
  return {
    app: 'Demo App',
    name: 'demo-app',
    version: '3.0.0',
    upgrade: LINK,
    verlink: `pear://0.42.${KEY}`,
    key: '/by-arch/linux-x64/app/Demo App.AppImage',
    dest: '/home/test/.local/bin/Demo App.AppImage',
    ...overrides
  }
}

test('Pear v3 install links accept only canonical root keys', () => {
  assert.equal(normalizePearInstallLink(`${LINK}/`), LINK)
  assert.equal(normalizePearInstallLink(`PEAR://${KEY.toUpperCase()}`), LINK)
  assert.throws(() => normalizePearInstallLink(`pear://${KEY}/worker.js`), /must not contain a path/)
  assert.throws(() => normalizePearInstallLink('pear://keet'), /52-character Pear key/)
  assert.throws(() => normalizePearInstallLink(`hyper://${KEY}`), /canonical 52-character Pear key/)
})

test('installer metadata is constrained to one GUI artifact and default OS destination', () => {
  assert.deepEqual(validateInstallEvent(installEvent(), {
    platform: 'linux',
    arch: 'x64',
    homeDir: '/home/test',
    expectedLink: LINK
  }), {
    app: 'Demo App',
    packageName: 'demo-app',
    version: '3.0.0',
    upgrade: LINK,
    verlink: `pear://0.42.${KEY}`,
    key: '/by-arch/linux-x64/app/Demo App.AppImage',
    dest: '/home/test/.local/bin/Demo App.AppImage'
  })
  assert.equal(validateInstallEvent(installEvent({ verlink: `pear://0.42.${KEY}.${'b'.repeat(52)}` }), {
    platform: 'linux', arch: 'x64', homeDir: '/home/test', expectedLink: LINK
  }).verlink, `pear://0.42.${KEY}.${'b'.repeat(52)}`)

  assert.throws(() => validateInstallEvent(installEvent({
    app: 'demo-cli',
    key: '/by-arch/linux-x64/app/demo-cli',
    dest: '/home/test/.local/bin/demo-cli'
  }), { platform: 'linux', homeDir: '/home/test', expectedLink: LINK }), /non-GUI executable target/)
  assert.throws(() => validateInstallEvent(installEvent({ key: '/by-arch/linux-arm64/app/Demo App.AppImage' }), {
    platform: 'linux', arch: 'x64', homeDir: '/home/test', expectedLink: LINK
  }), /non-GUI executable target/)
  assert.throws(() => validateInstallEvent(installEvent({ dest: '/tmp/Demo App.AppImage' }), {
    platform: 'linux', homeDir: '/home/test', expectedLink: LINK
  }), /unexpected Linux install destination/)
  assert.throws(() => validateInstallEvent(installEvent({ upgrade: `pear://${'b'.repeat(52)}` }), {
    platform: 'linux', homeDir: '/home/test', expectedLink: LINK
  }), /upgrade identity does not match/)
  assert.throws(() => validateInstallEvent(installEvent({ verlink: `${LINK}/worker.js` }), {
    platform: 'linux', homeDir: '/home/test', expectedLink: LINK
  }), /invalid version link/)
  assert.throws(() => validateInstallEvent(installEvent(), {
    platform: 'linux', homeDir: '/home/test', expectedLink: LINK, expectedAppName: 'Other App'
  }), /product name does not match/)
})

test('resolved installer targets reject mixed GUI and command-line packages before installation', () => {
  const app = {
    filename: 'Demo App',
    ext: '.AppImage',
    dest: '/home/test/.local/bin/Demo App.AppImage',
    isBin: false
  }
  assert.deepEqual(validateResolvedTargets([app], { platform: 'linux', homeDir: '/home/test' }), {
    app: 'Demo App',
    ext: '.AppImage',
    dest: '/home/test/.local/bin/Demo App.AppImage'
  })
  assert.throws(() => validateResolvedTargets([
    { filename: 'demo-cli', ext: '', dest: '/home/test/.local/bin/demo-cli', isBin: true },
    app
  ], { platform: 'linux', homeDir: '/home/test' }), /multiple installable targets/)
})

test('native installer records authoritative package metadata and closes its resources', async () => {
  let instance = null
  class FakeInstall extends EventEmitter {
    constructor (opts) {
      super()
      this.opts = opts
      this.closed = false
      instance = this
    }

    async ready () {
      this.emit('installing', { link: this.opts.link, host: 'linux-x64' })
      this.emit('stats', { peers: 2, download: { bytes: 1024, speed: 256 } })
      this.emit('app', installEvent())
      this.emit('final', { success: true, installed: 1, exists: [] })
    }

    async close () { this.closed = true }
  }

  const progress = []
  const launcher = new PearAppLauncher({
    platform: 'linux',
    arch: 'x64',
    homeDir: '/home/test',
    Install: FakeInstall
  })
  const record = await launcher.install({ id: 'demo', name: 'Demo', link: LINK, productName: 'Demo App', verification: 'author-signed' }, (event) => progress.push(event))

  assert.equal(instance.opts.link, LINK)
  assert.deepEqual(Object.keys(instance.opts), ['link'])
  assert.equal(instance.closed, true)
  assert.equal(record.app, 'Demo App')
  assert.equal(record.packageName, 'demo-app')
  assert.equal(record.dest, '/home/test/.local/bin/Demo App.AppImage')
  assert.equal(record.verification, 'author-signed')
  assert.deepEqual(progress.map((event) => event.phase), ['connecting', 'downloading', 'installing', 'complete'])
})

test('native installer rejects packages that expose a binary target', async () => {
  let closed = false
  class FakeInstall extends EventEmitter {
    async ready () {
      this.emit('app', installEvent({
        app: 'demo-cli',
        key: '/by-arch/linux-x64/app/demo-cli',
        dest: '/home/test/.local/bin/demo-cli'
      }))
    }

    async close () { closed = true }
  }

  const launcher = new PearAppLauncher({ platform: 'linux', arch: 'x64', homeDir: '/home/test', Install: FakeInstall })
  await assert.rejects(launcher.install({ id: 'demo', link: LINK }), /non-GUI executable target/)
  assert.equal(closed, true)
})

test('native Linux launch uses only the recorded installed artifact', async () => {
  let spawned = null
  const launcher = new PearAppLauncher({
    platform: 'linux',
    arch: 'x64',
    homeDir: '/home/test',
    fs: { existsSync: (filename) => filename === '/home/test/.local/bin/Demo App.AppImage' },
    spawn: (filename, args, opts) => {
      spawned = { filename, args, opts, unref: false }
      return { unref: () => { spawned.unref = true } }
    }
  })
  launcher.records.set(LINK, {
    id: 'demo',
    link: LINK,
    app: 'Demo App',
    packageName: 'demo-app',
    version: '3.0.0',
    dest: '/home/test/.local/bin/Demo App.AppImage',
    platform: 'linux',
    arch: 'x64'
  })

  const result = await launcher.launch(LINK)
  assert.equal(result.launched, true)
  assert.deepEqual(spawned, {
    filename: '/home/test/.local/bin/Demo App.AppImage',
    args: [],
    opts: { detached: true, stdio: 'ignore' },
    unref: true
  })
})

test('persisted native app records are revalidated before they can be launched', () => {
  const valid = {
    id: 'demo',
    link: LINK,
    app: 'Demo App',
    packageName: 'demo-app',
    version: '3.0.0',
    upgrade: LINK,
    verlink: `pear://0.42.${KEY}`,
    artifactKey: '/by-arch/linux-x64/app/Demo App.AppImage',
    dest: '/home/test/.local/bin/Demo App.AppImage',
    platform: 'linux',
    arch: 'x64'
  }
  const launcher = new PearAppLauncher({
    platform: 'linux',
    arch: 'x64',
    homeDir: '/home/test',
    dataFile: '/state/pear-v3-apps.json',
    fs: {
      readFileSync: () => JSON.stringify({ apps: [valid, { ...valid, link: `pear://${'b'.repeat(52)}`, dest: '/tmp/evil.AppImage' }] }),
      existsSync: () => true
    }
  })

  assert.equal(launcher.list().length, 1)
  assert.equal(launcher.list()[0].link, LINK)
})