|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright 2026 Easy Code team |
| 4 | + * https://github.com/OrionStarAI/DeepVCode |
| 5 | + * SPDX-License-Identifier: Apache-2.0 |
| 6 | + */ |
| 7 | + |
| 8 | +import { describe, it, expect } from 'vitest'; |
| 9 | +import { |
| 10 | + buildRelaunchScript, |
| 11 | + SelfUpdateTool, |
| 12 | + type RelaunchInstallMode, |
| 13 | +} from './self-update.js'; |
| 14 | +import type { Config } from '../config/config.js'; |
| 15 | + |
| 16 | +/** |
| 17 | + * SelfUpdateTool / buildRelaunchScript — 飞书模式下"更新并重启 / 仅重启"。 |
| 18 | + * |
| 19 | + * 进程不能自杀续命,故由一个 detached 的纯 JS 外挂脚本接力: |
| 20 | + * 父进程退出 → 外挂轮询父 PID 消失 → (按需)安装 → 拉起 easycode --feishu → 自删。 |
| 21 | + * |
| 22 | + * 三种安装模式(install): |
| 23 | + * - { type: 'none' } 仅重启,不安装 |
| 24 | + * - { type: 'npm' } npm i -g easycode-ai@latest |
| 25 | + * - { type: 'tgz', path } npm i -g <本地 tgz 绝对路径> |
| 26 | + * |
| 27 | + * 同一套外挂机制被 SelfUpdateTool 与 /feishu restart 共用,不造第二套轮子。 |
| 28 | + */ |
| 29 | +describe('buildRelaunchScript', () => { |
| 30 | + const base = { |
| 31 | + parentPid: 12345, |
| 32 | + relaunchCommand: 'easycode', |
| 33 | + relaunchArgs: ['--feishu'], |
| 34 | + scriptPath: '/tmp/easycode-relaunch-12345.js', |
| 35 | + }; |
| 36 | + |
| 37 | + const npmMode: RelaunchInstallMode = { type: 'npm', packageName: 'easycode-ai' }; |
| 38 | + const noneMode: RelaunchInstallMode = { type: 'none' }; |
| 39 | + const tgzMode: RelaunchInstallMode = { type: 'tgz', path: '/abs/easycode-ai-1.1.3.tgz' }; |
| 40 | + |
| 41 | + it('always embeds parent PID polling via process.kill', () => { |
| 42 | + const script = buildRelaunchScript({ ...base, install: npmMode }); |
| 43 | + expect(script).toContain('12345'); |
| 44 | + expect(script).toContain('process.kill'); |
| 45 | + }); |
| 46 | + |
| 47 | + it('npm mode embeds `<pkg>@latest` install', () => { |
| 48 | + const script = buildRelaunchScript({ ...base, install: npmMode }); |
| 49 | + expect(script).toContain('easycode-ai@latest'); |
| 50 | + expect(script).toContain('install'); |
| 51 | + expect(script).toContain('-g'); |
| 52 | + }); |
| 53 | + |
| 54 | + it('tgz mode embeds the local tgz absolute path install', () => { |
| 55 | + const script = buildRelaunchScript({ ...base, install: tgzMode }); |
| 56 | + expect(script).toContain('/abs/easycode-ai-1.1.3.tgz'); |
| 57 | + expect(script).toContain('install'); |
| 58 | + expect(script).toContain('-g'); |
| 59 | + // tgz 模式不应出现 @latest |
| 60 | + expect(script).not.toContain('@latest'); |
| 61 | + }); |
| 62 | + |
| 63 | + it('none mode (restart only) installs nothing — INSTALL_ARGS is null', () => { |
| 64 | + const script = buildRelaunchScript({ ...base, install: noneMode }); |
| 65 | + // 关键:安装参数被禁用(运行时跳过 npm install) |
| 66 | + expect(script).toContain('INSTALL_ARGS = null'); |
| 67 | + expect(script).not.toContain('@latest'); |
| 68 | + // 仍然要重启 |
| 69 | + expect(script).toContain('easycode'); |
| 70 | + expect(script).toContain('--feishu'); |
| 71 | + }); |
| 72 | + |
| 73 | + it('always embeds the relaunch command and args', () => { |
| 74 | + for (const install of [npmMode, noneMode, tgzMode]) { |
| 75 | + const script = buildRelaunchScript({ ...base, install }); |
| 76 | + expect(script).toContain('easycode'); |
| 77 | + expect(script).toContain('--feishu'); |
| 78 | + } |
| 79 | + }); |
| 80 | + |
| 81 | + it('always self-deletes the temp script', () => { |
| 82 | + for (const install of [npmMode, noneMode, tgzMode]) { |
| 83 | + const script = buildRelaunchScript({ ...base, install }); |
| 84 | + expect(script).toContain('unlink'); |
| 85 | + } |
| 86 | + }); |
| 87 | + |
| 88 | + it('always uses detached + unref to outlive itself', () => { |
| 89 | + for (const install of [npmMode, noneMode, tgzMode]) { |
| 90 | + const script = buildRelaunchScript({ ...base, install }); |
| 91 | + expect(script).toContain('detached'); |
| 92 | + expect(script).toContain('unref'); |
| 93 | + } |
| 94 | + }); |
| 95 | + |
| 96 | + it('produces valid JavaScript for all modes', () => { |
| 97 | + for (const install of [npmMode, noneMode, tgzMode]) { |
| 98 | + const script = buildRelaunchScript({ ...base, install }); |
| 99 | + expect(() => new Function(script)).not.toThrow(); |
| 100 | + } |
| 101 | + }); |
| 102 | + |
| 103 | + it('embeds args via JSON to avoid injection', () => { |
| 104 | + const script = buildRelaunchScript({ |
| 105 | + ...base, |
| 106 | + install: noneMode, |
| 107 | + relaunchArgs: ['--feishu', '--weird "arg"'], |
| 108 | + }); |
| 109 | + expect(script).toContain(JSON.stringify(['--feishu', '--weird "arg"'])); |
| 110 | + }); |
| 111 | + |
| 112 | + it('does not branch on OS (single cross-platform path)', () => { |
| 113 | + const script = buildRelaunchScript({ ...base, install: npmMode }); |
| 114 | + expect(script).not.toContain('cmd.exe'); |
| 115 | + expect(script).not.toContain('/bin/bash'); |
| 116 | + }); |
| 117 | + |
| 118 | + it('embeds tgz path via JSON to handle spaces/backslashes safely', () => { |
| 119 | + const winPath = 'C:\\Users\\me\\pkgs\\easycode-ai 1.1.3.tgz'; |
| 120 | + const script = buildRelaunchScript({ |
| 121 | + ...base, |
| 122 | + install: { type: 'tgz', path: winPath }, |
| 123 | + }); |
| 124 | + expect(script).toContain(JSON.stringify(winPath)); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('SelfUpdateTool', () => { |
| 129 | + const makeConfig = (): Config => |
| 130 | + ({ getModel: () => 'test-model' }) as unknown as Config; |
| 131 | + |
| 132 | + it('has the correct tool name and no required params', () => { |
| 133 | + const tool = new SelfUpdateTool(makeConfig()); |
| 134 | + expect(tool.name).toBe('self_update'); |
| 135 | + expect(tool.schema.parameters?.required ?? []).toEqual([]); |
| 136 | + }); |
| 137 | + |
| 138 | + it('schema exposes action and source params to the model', () => { |
| 139 | + const tool = new SelfUpdateTool(makeConfig()); |
| 140 | + const props = (tool.schema.parameters?.properties ?? {}) as Record<string, unknown>; |
| 141 | + expect(props).toHaveProperty('action'); |
| 142 | + expect(props).toHaveProperty('source'); |
| 143 | + }); |
| 144 | + |
| 145 | + it('validates action enum', () => { |
| 146 | + const tool = new SelfUpdateTool(makeConfig()); |
| 147 | + expect(tool.validateToolParams({ action: 'update_and_restart' })).toBeNull(); |
| 148 | + expect(tool.validateToolParams({ action: 'restart_only' })).toBeNull(); |
| 149 | + expect(tool.validateToolParams({ action: 'bogus' as never })).not.toBeNull(); |
| 150 | + }); |
| 151 | + |
| 152 | + it('requires source path when source is a local tgz', () => { |
| 153 | + const tool = new SelfUpdateTool(makeConfig()); |
| 154 | + // source=local 但未给 path → 校验失败 |
| 155 | + expect( |
| 156 | + tool.validateToolParams({ action: 'update_and_restart', source: 'local' }), |
| 157 | + ).not.toBeNull(); |
| 158 | + // 给了 path → 通过 |
| 159 | + expect( |
| 160 | + tool.validateToolParams({ |
| 161 | + action: 'update_and_restart', |
| 162 | + source: 'local', |
| 163 | + sourcePath: '/abs/pkg.tgz', |
| 164 | + }), |
| 165 | + ).toBeNull(); |
| 166 | + }); |
| 167 | + |
| 168 | + it('restart_only ignores source and is always valid', () => { |
| 169 | + const tool = new SelfUpdateTool(makeConfig()); |
| 170 | + expect(tool.validateToolParams({ action: 'restart_only' })).toBeNull(); |
| 171 | + }); |
| 172 | + |
| 173 | + it('description mentions update/restart', () => { |
| 174 | + const tool = new SelfUpdateTool(makeConfig()); |
| 175 | + expect(tool.getDescription({}).toLowerCase()).toMatch(/updat|restart|重启|更新/); |
| 176 | + }); |
| 177 | +}); |
0 commit comments