verify-npm-update.mjs 5.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { execFileSync } from 'node:child_process'
  4. import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { createServer } from 'node:http'
  8. import { createHash, randomUUID } from 'node:crypto'
  9. import { stageNpmRelease, verifyPreparedRelease } from '../bin/hermes-yaoyao-updater.mjs'
  10. import { currentRelease, LaunchAgentService, localRequest, switchRelease, transitionService } from '../bin/lib/service-update.mjs'
  11. import { writeNpmFixture } from '../tests/fixtures/npm-release.mjs'
  12. test('real npm archives prepare alongside a live service; explicit switching and failed-start recovery preserve data', { skip: process.platform !== 'darwin', timeout: 90000 }, async () => {
  13. const root = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-npm-live-'))), home = join(root, 'data'), releaseRoot = join(root, 'programs')
  14. mkdirSync(home); mkdirSync(join(home, 'updates')); mkdirSync(releaseRoot)
  15. let archive
  16. const registry = createServer((req, res) => {
  17. if (req.url !== '/release.tgz') { res.statusCode = 404; res.end(); return }
  18. res.setHeader('Content-Length', archive.length); res.end(archive)
  19. })
  20. await new Promise(done => registry.listen(0, '127.0.0.1', done))
  21. const probe = createServer(); await new Promise(done => probe.listen(0, '127.0.0.1', done))
  22. const port = probe.address().port; await new Promise(done => probe.close(done))
  23. const label = `cn.samien.yaoyao.npm-test.${randomUUID()}`
  24. class FixtureService extends LaunchAgentService {
  25. verify(expected) { return super.verify(expected, expected?.version === '0.3.1' ? 1500 : 10000) }
  26. }
  27. const driver = new FixtureService({ home, releaseRoot, port, label, plistPath: join(home, 'updates/service.plist') })
  28. const old = join(root, 'old'); writeNpmFixture(old, '0.2.0')
  29. function packageJob(version, broken = false) {
  30. const source = join(root, `package-${version}`), target = writeNpmFixture(source, version, broken)
  31. const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', root], { cwd: source, encoding: 'utf8' }))
  32. archive = readFileSync(join(root, packed.filename))
  33. const npm = { version, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}`, tarball: `http://127.0.0.1:${registry.address().port}/release.tgz` }
  34. const id = randomUUID(), jobPath = join(home, 'updates', `${id}.json`)
  35. const job = { id, operation: 'update', state: 'queued', target, plan: { home, releaseRoot, port, source: 'npm:@lsamien/yaoyao', npm, target } }
  36. writeFileSync(jobPath, JSON.stringify(job))
  37. return { job, jobPath }
  38. }
  39. try {
  40. switchRelease(releaseRoot, old); await driver.start(old); await driver.verify()
  41. const initialPID = driver.pid()
  42. writeFileSync(join(home, 'user.txt'), 'original data')
  43. const first = packageJob('0.3.0')
  44. const prepared = await stageNpmRelease(first.job, first.jobPath)
  45. assert.equal(JSON.parse(readFileSync(first.jobPath)).state, 'prepared')
  46. assert.equal(driver.pid(), initialPID, 'preparation must leave the old service running')
  47. assert.equal(currentRelease(releaseRoot), old)
  48. assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.2.0')
  49. const finalRoot = verifyPreparedRelease({ ...first.job.plan, prepared }, first.job.target)
  50. await transitionService({ home, releaseRoot, driver, finalRoot })
  51. assert.notEqual(driver.pid(), initialPID)
  52. assert.equal(currentRelease(releaseRoot), finalRoot)
  53. assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.3.0')
  54. assert.equal(readFileSync(join(home, 'user.txt'), 'utf8'), 'original data')
  55. assert.ok(readFileSync(join(old, 'release.json')), 'retain the old release')
  56. const second = packageJob('0.3.1', true), healthyPID = driver.pid()
  57. const bad = await stageNpmRelease(second.job, second.jobPath)
  58. assert.equal(driver.pid(), healthyPID)
  59. await assert.rejects(transitionService({ home, releaseRoot, driver, finalRoot: bad.root }), /核验失败/)
  60. assert.equal(currentRelease(releaseRoot), finalRoot)
  61. assert.equal((await localRequest(`http://127.0.0.1:${port}/api/status`)).body.version, '0.3.0')
  62. assert.equal(readFileSync(join(home, 'user.txt'), 'utf8'), 'original data', 'failed startup must restore the pre-switch data snapshot')
  63. const third = packageJob('0.3.2'), survivingPID = driver.pid()
  64. third.job.plan.npm.integrity = `sha512-${Buffer.alloc(64).toString('base64')}`
  65. await assert.rejects(stageNpmRelease(third.job, third.jobPath), /完整性校验失败/)
  66. assert.equal(driver.pid(), survivingPID)
  67. assert.equal(currentRelease(releaseRoot), finalRoot)
  68. console.log('Verified npm preparation, explicit restart, data recovery and integrity rejection in an isolated LaunchAgent.')
  69. } finally {
  70. await driver.stop().catch(() => {})
  71. await new Promise(done => { registry.close(done); registry.closeAllConnections() })
  72. rmSync(root, { recursive: true, force: true })
  73. }
  74. })