install-smoke.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import { createRequire } from 'node:module'
  6. import { createHash } from 'node:crypto'
  7. import { execFileSync, spawn } from 'node:child_process'
  8. import { root as sourceRoot } from './version.mjs'
  9. import { packageFiles } from './tar.mjs'
  10. const fromRegistry = process.argv.includes('--registry')
  11. const args = process.argv.slice(2).filter(value => value !== '--' && value !== '--registry')
  12. assert.ok(args[0], 'Usage: pnpm release:smoke <main.tgz> [embedding.tgz] [meta.tgz] [--registry] [--root <new-directory>]')
  13. const main = path.resolve(args[0])
  14. const embedding = args[1] && args[1] !== '--root' ? path.resolve(args[1]) : undefined
  15. const meta = args[2] && args[2] !== '--root' ? path.resolve(args[2]) : undefined
  16. const rootIndex = args.indexOf('--root')
  17. const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : fs.mkdtempSync(path.join(os.tmpdir(), 'scriptor-install-'))
  18. assert.ok(root !== sourceRoot && !root.startsWith(sourceRoot + path.sep), 'Use an isolated directory outside the source checkout')
  19. assert.ok(!root.includes(' '), 'The current Windows DSH plugin installer requires a path without spaces')
  20. if (fs.existsSync(root)) assert.equal(fs.readdirSync(root).length, 0, 'Isolation directory must be empty')
  21. fs.mkdirSync(root, { recursive: true })
  22. const host = path.join(root, 'host')
  23. const home = path.join(root, 'home')
  24. const workspace = path.join(root, 'workspace')
  25. for (const directory of [host, home, workspace]) fs.mkdirSync(directory)
  26. fs.writeFileSync(path.join(host, 'package.json'), JSON.stringify({ name: 'scriptor-isolated-host', private: true, type: 'module' }))
  27. fs.writeFileSync(path.join(root, 'npmrc'), '')
  28. const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(npm_|pnpm_)|TOKEN|API_KEY|SECRET|DSH_|AGENTS_HOME|NODE_PATH/i.test(key)))
  29. Object.assign(env, { DSH_HOME: home, DSH_AGENTS_HOME: path.join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', NPM_CONFIG_USERCONFIG: path.join(root, 'npmrc') })
  30. const run = (entry, rest, cwd = workspace, timeout = 240000) => execFileSync(process.execPath, [entry, ...rest], { cwd, env, encoding: 'utf8', windowsHide: true, timeout, maxBuffer: 24 * 1024 * 1024 })
  31. // Node matches permission resources after canonicalizing them. On a Windows volume with 8.3
  32. // short names (hosted runners keep that setting) the temp root is also spelled RUNNER~1, so
  33. // one granted spelling is not enough; grant the short spelling, the canonical one and a
  34. // wildcard over the temp tree. Other platforms keep the single exact grant.
  35. const permissionGrants = access => {
  36. if (process.platform !== 'win32') return [`--allow-fs-${access}=${root}`]
  37. const canonical = (() => { try { return fs.realpathSync.native(root) } catch { return root } })()
  38. const spellings = [...new Set([root, canonical, `${root}${path.sep}`, path.join(os.tmpdir(), '*')])]
  39. // Node takes one resource per flag; a path-list form is not accepted.
  40. return spellings.map(value => `--allow-fs-${access}=${value}`)
  41. }
  42. const npmCandidates = [process.env.NPM_CLI_ENTRY, path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')].filter(Boolean)
  43. const npm = npmCandidates.find(file => fs.existsSync(file))
  44. assert.ok(npm, 'npm-cli.js was not found; set NPM_CLI_ENTRY to the installed npm CLI')
  45. const report = { ok: false, node: process.version, packageSha256: createHash('sha256').update(fs.readFileSync(main)).digest('hex'), checks: {} }
  46. const reportPath = path.join(root, 'install-report.json')
  47. const log = (name, output) => { fs.writeFileSync(path.join(root, `${name}.log`), output); console.log(`[install] ${name}`) }
  48. const packageSpec = file => {
  49. const manifest = JSON.parse(packageFiles(file).get('package.json'))
  50. return `${manifest.name}@${manifest.version}`
  51. }
  52. let fixtureRegistry
  53. let fixtureRegistryUrl
  54. try {
  55. // Hosted Windows runners with a cold npm cache exceeded 4 minutes for the DSH tree; the network-bound step gets its own bound.
  56. log('install-host', run(npm, ['install', '--prefix', host, '--save-exact', '--no-audit', '--no-fund', '@deepseek-ai/dsh@0.1.5-rc.2', 'pnpm@11.27.1'], host, 15 * 60 * 1000))
  57. const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') ?? 'PATH'
  58. env[pathKey] = path.join(host, 'node_modules/.bin') + path.delimiter + (env[pathKey] ?? '')
  59. report.hostPackageManager = 'pnpm@11.27.1'
  60. const require = createRequire(path.join(host, 'package.json'))
  61. const hostAnchor = require.resolve('@deepseek-ai/dsh/package.json')
  62. const cli = path.join(path.dirname(hostAnchor), 'lib/bin.js')
  63. assert.equal(run(cli, ['--version']).trim(), '0.1.5-rc.2')
  64. report.checks.freshHost = true
  65. log('web-profile', run(cli, ['--profile', 'scriptor-test', '--from-default-profile', 'web', '--dump-config']))
  66. const copiedMain = path.join(root, 'main.tgz')
  67. fs.copyFileSync(main, copiedMain)
  68. const mainSpec = fromRegistry ? packageSpec(main) : copiedMain
  69. log('add-main', run(cli, ['plugin', '--profile', 'scriptor-test', 'add', mainSpec]))
  70. report.installSource = fromRegistry ? 'npm' : 'tarball'
  71. const profile = path.join(home, 'profiles/scriptor-test')
  72. const dump = run(cli, ['--profile', 'scriptor-test', '--dump-config'])
  73. assert.ok(dump.includes('@linfengqaqtat/dsh-scriptor'))
  74. report.checks.configuration = true
  75. const installed = require.resolve('@linfengqaqtat/dsh-scriptor/package.json', { paths: [profile] })
  76. const installedRoot = path.dirname(installed)
  77. assert.ok(installedRoot.startsWith(root + path.sep))
  78. const prepare = path.join(root, 'prepare-profile.mjs')
  79. fs.copyFileSync(path.join(sourceRoot, 'packages/bundle/tests/fixtures/packaging-prepare.mjs'), prepare)
  80. log('prepare-profile', run(prepare, [profile, hostAnchor]))
  81. const script = path.join(root, 'isolation.mjs')
  82. fs.copyFileSync(path.join(sourceRoot, 'packages/bundle/tests/fixtures/packaging-isolation.mjs'), script)
  83. const isolatedReport = path.join(root, 'source-isolation.json')
  84. const blocked = path.join(sourceRoot, 'packages/bundle/src/index.ts')
  85. const output = execFileSync(process.execPath, ['--permission', ...permissionGrants('read'), ...permissionGrants('write'), `--allow-fs-read=${path.dirname(process.execPath)}`, script, profile, hostAnchor, blocked, isolatedReport], { cwd: workspace, env, encoding: 'utf8', windowsHide: true, timeout: 45000 })
  86. log('source-isolation', output)
  87. assert.equal(JSON.parse(fs.readFileSync(isolatedReport, 'utf8')).ok, true)
  88. report.checks.sourceDeniedRealLoader = true
  89. report.checks.skills = 10
  90. if (embedding) {
  91. const copiedEmbedding = path.join(root, 'embedding.tgz')
  92. fs.copyFileSync(embedding, copiedEmbedding)
  93. log('add-embedding', run(cli, ['plugin', '--profile', 'scriptor-test', 'add', fromRegistry ? packageSpec(embedding) : copiedEmbedding]))
  94. assert.ok(run(cli, ['--profile', 'scriptor-test', '--dump-config']).includes('webnovel-embedding-provider'))
  95. report.checks.embeddingInstalled = true
  96. }
  97. fs.writeFileSync(path.join(workspace, 'author-sentinel.txt'), 'synthetic author asset')
  98. log('remove-main', run(cli, ['plugin', '--profile', 'scriptor-test', 'remove', '@linfengqaqtat/dsh-scriptor']))
  99. assert.ok(!/@linfengqaqtat\/dsh-scriptor(?!-)/.test(run(cli, ['--profile', 'scriptor-test', '--dump-config'])))
  100. assert.equal(fs.readFileSync(path.join(workspace, 'author-sentinel.txt'), 'utf8'), 'synthetic author asset')
  101. log('reinstall-main', run(cli, ['plugin', '--profile', 'scriptor-test', 'add', mainSpec]))
  102. assert.ok(/@linfengqaqtat\/dsh-scriptor(?!-)/.test(run(cli, ['--profile', 'scriptor-test', '--dump-config'])))
  103. assert.equal(fs.readFileSync(path.join(workspace, 'author-sentinel.txt'), 'utf8'), 'synthetic author asset')
  104. report.checks.uninstallReinstall = true
  105. if (meta) {
  106. assert.ok(embedding, 'Full package validation requires the embedding tarball')
  107. const fullName = 'scriptor-full-test'
  108. log('full-web-profile', run(cli, ['--profile', fullName, '--from-default-profile', 'web', '--dump-config']))
  109. const fullProfile = path.join(home, 'profiles', fullName)
  110. assert.ok(!run(cli, ['--profile', fullName, '--dump-config']).includes('id: webnovel'))
  111. const copiedMeta = path.join(root, 'meta.tgz')
  112. fs.copyFileSync(meta, copiedMeta)
  113. if (!fromRegistry) {
  114. // Serve the two exact tarballs without changing their manifests or adding
  115. // workspace configuration to a user profile. Other packages use npmjs.
  116. fixtureRegistry = spawn(process.execPath, [path.join(sourceRoot, 'scripts/release/fixture-registry.mjs'), copiedMain, path.join(root, 'embedding.tgz')], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'inherit'] })
  117. fixtureRegistryUrl = await new Promise((resolve, reject) => {
  118. const timer = setTimeout(() => reject(new Error('Fixture registry startup timed out')), 10000)
  119. let output = ''
  120. fixtureRegistry.once('error', error => { clearTimeout(timer); reject(error) })
  121. fixtureRegistry.once('exit', code => { clearTimeout(timer); reject(new Error(`Fixture registry exited: ${code}`)) })
  122. fixtureRegistry.stdout.on('data', data => {
  123. output += data
  124. if (output.includes('\n')) { clearTimeout(timer); resolve(output.trim()) }
  125. })
  126. })
  127. assert.match(fixtureRegistryUrl, /^http:\/\/127\.0\.0\.1:\d+\/$/)
  128. }
  129. const fullSpec = fromRegistry ? packageSpec(meta) : copiedMeta
  130. const registryArgs = fixtureRegistryUrl ? ['--registry', fixtureRegistryUrl] : []
  131. const fullDump = () => run(cli, ['--profile', fullName, '--dump-config'])
  132. const verifyFull = label => {
  133. const config = fullDump()
  134. assert.equal((config.match(/id: webnovel(?:\r?\n|$)/g) ?? []).length, 1)
  135. assert.equal((config.match(/id: webnovel-embeddings(?:\r?\n|$)/g) ?? []).length, 1)
  136. log(`${label}-prepare`, run(prepare, [fullProfile, hostAnchor]))
  137. const fullReport = path.join(root, `${label}-isolation.json`)
  138. log(`${label}-loader`, execFileSync(process.execPath, ['--permission', ...permissionGrants('read'), ...permissionGrants('write'), `--allow-fs-read=${path.dirname(process.execPath)}`, script, fullProfile, hostAnchor, blocked, fullReport, '--embedding'], { cwd: workspace, env, encoding: 'utf8', windowsHide: true, timeout: 45000 }))
  139. assert.equal(JSON.parse(fs.readFileSync(fullReport, 'utf8')).embeddingLoaded, true)
  140. }
  141. log('add-meta', run(cli, ['plugin', '--profile', fullName, 'add', fullSpec, ...registryArgs]))
  142. verifyFull('full')
  143. // pnpm remove has no --registry option, but removing one package re-resolves
  144. // the remaining tree, so the unpublished dependencies are located through the
  145. // profile's own npmrc instead.
  146. if (fixtureRegistryUrl) fs.writeFileSync(path.join(fullProfile, '.npmrc'), `registry=${fixtureRegistryUrl}\n`)
  147. log('remove-meta', run(cli, ['plugin', '--profile', fullName, 'remove', '@linfengqaqtat/dsh-scriptor-full']))
  148. assert.ok(!fullDump().includes('@linfengqaqtat/dsh-scriptor'))
  149. assert.ok(!fullDump().includes('webnovel-embedding-provider'))
  150. log('reinstall-meta', run(cli, ['plugin', '--profile', fullName, 'add', fullSpec, ...registryArgs]))
  151. verifyFull('full-reinstall')
  152. report.checks.metaFreshProfile = true
  153. report.checks.metaUninstallReinstall = true
  154. }
  155. report.ok = true
  156. } finally {
  157. fixtureRegistry?.kill()
  158. fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n')
  159. console.log(JSON.stringify({ ...report, report: reportPath }))
  160. }