| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- import assert from 'node:assert/strict'
- import fs from 'node:fs'
- import os from 'node:os'
- import path from 'node:path'
- import { createRequire } from 'node:module'
- import { createHash } from 'node:crypto'
- import { execFileSync, spawn } from 'node:child_process'
- import { root as sourceRoot } from './version.mjs'
- import { packageFiles } from './tar.mjs'
- const fromRegistry = process.argv.includes('--registry')
- const args = process.argv.slice(2).filter(value => value !== '--' && value !== '--registry')
- assert.ok(args[0], 'Usage: pnpm release:smoke <main.tgz> [embedding.tgz] [meta.tgz] [--registry] [--root <new-directory>]')
- const main = path.resolve(args[0])
- const embedding = args[1] && args[1] !== '--root' ? path.resolve(args[1]) : undefined
- const meta = args[2] && args[2] !== '--root' ? path.resolve(args[2]) : undefined
- const rootIndex = args.indexOf('--root')
- const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : fs.mkdtempSync(path.join(os.tmpdir(), 'scriptor-install-'))
- assert.ok(root !== sourceRoot && !root.startsWith(sourceRoot + path.sep), 'Use an isolated directory outside the source checkout')
- assert.ok(!root.includes(' '), 'The current Windows DSH plugin installer requires a path without spaces')
- if (fs.existsSync(root)) assert.equal(fs.readdirSync(root).length, 0, 'Isolation directory must be empty')
- fs.mkdirSync(root, { recursive: true })
- const host = path.join(root, 'host')
- const home = path.join(root, 'home')
- const workspace = path.join(root, 'workspace')
- for (const directory of [host, home, workspace]) fs.mkdirSync(directory)
- fs.writeFileSync(path.join(host, 'package.json'), JSON.stringify({ name: 'scriptor-isolated-host', private: true, type: 'module' }))
- fs.writeFileSync(path.join(root, 'npmrc'), '')
- const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(npm_|pnpm_)|TOKEN|API_KEY|SECRET|DSH_|AGENTS_HOME|NODE_PATH/i.test(key)))
- Object.assign(env, { DSH_HOME: home, DSH_AGENTS_HOME: path.join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', NPM_CONFIG_USERCONFIG: path.join(root, 'npmrc') })
- const run = (entry, rest, cwd = workspace, timeout = 240000) => execFileSync(process.execPath, [entry, ...rest], { cwd, env, encoding: 'utf8', windowsHide: true, timeout, maxBuffer: 24 * 1024 * 1024 })
- // Node matches permission resources after canonicalizing them. On a Windows volume with 8.3
- // short names (hosted runners keep that setting) the temp root is also spelled RUNNER~1, so
- // one granted spelling is not enough; grant the short spelling, the canonical one and a
- // wildcard over the temp tree. Other platforms keep the single exact grant.
- const permissionGrants = access => {
- if (process.platform !== 'win32') return [`--allow-fs-${access}=${root}`]
- const canonical = (() => { try { return fs.realpathSync.native(root) } catch { return root } })()
- const spellings = [...new Set([root, canonical, `${root}${path.sep}`, path.join(os.tmpdir(), '*')])]
- // Node takes one resource per flag; a path-list form is not accepted.
- return spellings.map(value => `--allow-fs-${access}=${value}`)
- }
- 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)
- const npm = npmCandidates.find(file => fs.existsSync(file))
- assert.ok(npm, 'npm-cli.js was not found; set NPM_CLI_ENTRY to the installed npm CLI')
- const report = { ok: false, node: process.version, packageSha256: createHash('sha256').update(fs.readFileSync(main)).digest('hex'), checks: {} }
- const reportPath = path.join(root, 'install-report.json')
- const log = (name, output) => { fs.writeFileSync(path.join(root, `${name}.log`), output); console.log(`[install] ${name}`) }
- const packageSpec = file => {
- const manifest = JSON.parse(packageFiles(file).get('package.json'))
- return `${manifest.name}@${manifest.version}`
- }
- let fixtureRegistry
- let fixtureRegistryUrl
- try {
- // Hosted Windows runners with a cold npm cache exceeded 4 minutes for the DSH tree; the network-bound step gets its own bound.
- 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))
- const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') ?? 'PATH'
- env[pathKey] = path.join(host, 'node_modules/.bin') + path.delimiter + (env[pathKey] ?? '')
- report.hostPackageManager = 'pnpm@11.27.1'
- const require = createRequire(path.join(host, 'package.json'))
- const hostAnchor = require.resolve('@deepseek-ai/dsh/package.json')
- const cli = path.join(path.dirname(hostAnchor), 'lib/bin.js')
- assert.equal(run(cli, ['--version']).trim(), '0.1.5-rc.2')
- report.checks.freshHost = true
- log('web-profile', run(cli, ['--profile', 'scriptor-test', '--from-default-profile', 'web', '--dump-config']))
- const copiedMain = path.join(root, 'main.tgz')
- fs.copyFileSync(main, copiedMain)
- const mainSpec = fromRegistry ? packageSpec(main) : copiedMain
- log('add-main', run(cli, ['plugin', '--profile', 'scriptor-test', 'add', mainSpec]))
- report.installSource = fromRegistry ? 'npm' : 'tarball'
- const profile = path.join(home, 'profiles/scriptor-test')
- const dump = run(cli, ['--profile', 'scriptor-test', '--dump-config'])
- assert.ok(dump.includes('@linfengqaqtat/dsh-scriptor'))
- report.checks.configuration = true
- const installed = require.resolve('@linfengqaqtat/dsh-scriptor/package.json', { paths: [profile] })
- const installedRoot = path.dirname(installed)
- assert.ok(installedRoot.startsWith(root + path.sep))
- const prepare = path.join(root, 'prepare-profile.mjs')
- fs.copyFileSync(path.join(sourceRoot, 'packages/bundle/tests/fixtures/packaging-prepare.mjs'), prepare)
- log('prepare-profile', run(prepare, [profile, hostAnchor]))
- const script = path.join(root, 'isolation.mjs')
- fs.copyFileSync(path.join(sourceRoot, 'packages/bundle/tests/fixtures/packaging-isolation.mjs'), script)
- const isolatedReport = path.join(root, 'source-isolation.json')
- const blocked = path.join(sourceRoot, 'packages/bundle/src/index.ts')
- 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 })
- log('source-isolation', output)
- assert.equal(JSON.parse(fs.readFileSync(isolatedReport, 'utf8')).ok, true)
- report.checks.sourceDeniedRealLoader = true
- report.checks.skills = 10
- if (embedding) {
- const copiedEmbedding = path.join(root, 'embedding.tgz')
- fs.copyFileSync(embedding, copiedEmbedding)
- log('add-embedding', run(cli, ['plugin', '--profile', 'scriptor-test', 'add', fromRegistry ? packageSpec(embedding) : copiedEmbedding]))
- assert.ok(run(cli, ['--profile', 'scriptor-test', '--dump-config']).includes('webnovel-embedding-provider'))
- report.checks.embeddingInstalled = true
- }
- fs.writeFileSync(path.join(workspace, 'author-sentinel.txt'), 'synthetic author asset')
- log('remove-main', run(cli, ['plugin', '--profile', 'scriptor-test', 'remove', '@linfengqaqtat/dsh-scriptor']))
- assert.ok(!/@linfengqaqtat\/dsh-scriptor(?!-)/.test(run(cli, ['--profile', 'scriptor-test', '--dump-config'])))
- assert.equal(fs.readFileSync(path.join(workspace, 'author-sentinel.txt'), 'utf8'), 'synthetic author asset')
- log('reinstall-main', run(cli, ['plugin', '--profile', 'scriptor-test', 'add', mainSpec]))
- assert.ok(/@linfengqaqtat\/dsh-scriptor(?!-)/.test(run(cli, ['--profile', 'scriptor-test', '--dump-config'])))
- assert.equal(fs.readFileSync(path.join(workspace, 'author-sentinel.txt'), 'utf8'), 'synthetic author asset')
- report.checks.uninstallReinstall = true
- if (meta) {
- assert.ok(embedding, 'Full package validation requires the embedding tarball')
- const fullName = 'scriptor-full-test'
- log('full-web-profile', run(cli, ['--profile', fullName, '--from-default-profile', 'web', '--dump-config']))
- const fullProfile = path.join(home, 'profiles', fullName)
- assert.ok(!run(cli, ['--profile', fullName, '--dump-config']).includes('id: webnovel'))
- const copiedMeta = path.join(root, 'meta.tgz')
- fs.copyFileSync(meta, copiedMeta)
- if (!fromRegistry) {
- // Serve the two exact tarballs without changing their manifests or adding
- // workspace configuration to a user profile. Other packages use npmjs.
- 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'] })
- fixtureRegistryUrl = await new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error('Fixture registry startup timed out')), 10000)
- let output = ''
- fixtureRegistry.once('error', error => { clearTimeout(timer); reject(error) })
- fixtureRegistry.once('exit', code => { clearTimeout(timer); reject(new Error(`Fixture registry exited: ${code}`)) })
- fixtureRegistry.stdout.on('data', data => {
- output += data
- if (output.includes('\n')) { clearTimeout(timer); resolve(output.trim()) }
- })
- })
- assert.match(fixtureRegistryUrl, /^http:\/\/127\.0\.0\.1:\d+\/$/)
- }
- const fullSpec = fromRegistry ? packageSpec(meta) : copiedMeta
- const registryArgs = fixtureRegistryUrl ? ['--registry', fixtureRegistryUrl] : []
- const fullDump = () => run(cli, ['--profile', fullName, '--dump-config'])
- const verifyFull = label => {
- const config = fullDump()
- assert.equal((config.match(/id: webnovel(?:\r?\n|$)/g) ?? []).length, 1)
- assert.equal((config.match(/id: webnovel-embeddings(?:\r?\n|$)/g) ?? []).length, 1)
- log(`${label}-prepare`, run(prepare, [fullProfile, hostAnchor]))
- const fullReport = path.join(root, `${label}-isolation.json`)
- 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 }))
- assert.equal(JSON.parse(fs.readFileSync(fullReport, 'utf8')).embeddingLoaded, true)
- }
- log('add-meta', run(cli, ['plugin', '--profile', fullName, 'add', fullSpec, ...registryArgs]))
- verifyFull('full')
- // pnpm remove has no --registry option, but removing one package re-resolves
- // the remaining tree, so the unpublished dependencies are located through the
- // profile's own npmrc instead.
- if (fixtureRegistryUrl) fs.writeFileSync(path.join(fullProfile, '.npmrc'), `registry=${fixtureRegistryUrl}\n`)
- log('remove-meta', run(cli, ['plugin', '--profile', fullName, 'remove', '@linfengqaqtat/dsh-scriptor-full']))
- assert.ok(!fullDump().includes('@linfengqaqtat/dsh-scriptor'))
- assert.ok(!fullDump().includes('webnovel-embedding-provider'))
- log('reinstall-meta', run(cli, ['plugin', '--profile', fullName, 'add', fullSpec, ...registryArgs]))
- verifyFull('full-reinstall')
- report.checks.metaFreshProfile = true
- report.checks.metaUninstallReinstall = true
- }
- report.ok = true
- } finally {
- fixtureRegistry?.kill()
- fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n')
- console.log(JSON.stringify({ ...report, report: reportPath }))
- }
|