|
1 | 1 | import assert from 'node:assert/strict'; |
2 | 2 | import { existsSync } from 'node:fs'; |
3 | | -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; |
| 3 | +import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; |
4 | 4 | import { createRequire } from 'node:module'; |
5 | 5 | import { tmpdir } from 'node:os'; |
6 | | -import { dirname, join, resolve } from 'node:path'; |
| 6 | +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; |
7 | 7 | import { fileURLToPath } from 'node:url'; |
8 | 8 | import { ownerBrowser } from '@repo/browser-testing/owner-browser'; |
9 | 9 | import metadata from '../package.json'; |
@@ -55,11 +55,17 @@ const gatewayPort = gatewayReservation.port!; |
55 | 55 | // The setup/removal tests below require an offline gateway, not an HTTP 503 listener. |
56 | 56 | await gatewayReservation.stop(true); |
57 | 57 |
|
58 | | -async function command(input: string[] | { args: string[]; stdin: string }): Promise<string> { |
59 | | - const { args, stdin } = Array.isArray(input) ? { args: input, stdin: undefined } : input; |
| 58 | +async function command( |
| 59 | + input: string[] | { args: string[]; stdin?: string; env?: NodeJS.ProcessEnv }, |
| 60 | +): Promise<string> { |
| 61 | + const { |
| 62 | + args, |
| 63 | + stdin, |
| 64 | + env: overrides, |
| 65 | + } = Array.isArray(input) ? { args: input, stdin: undefined, env: undefined } : input; |
60 | 66 | const process = Bun.spawn(args, { |
61 | 67 | cwd: directory, |
62 | | - env, |
| 68 | + env: { ...env, ...overrides }, |
63 | 69 | stdin: stdin === undefined ? 'ignore' : new Blob([stdin]), |
64 | 70 | stdout: 'pipe', |
65 | 71 | stderr: 'pipe', |
@@ -189,16 +195,24 @@ config.models={providers:{fixture:{baseUrl:${JSON.stringify(`${model.origin}/v1` |
189 | 195 | ); |
190 | 196 | // A stale operation record must not mistake this live, unrelated process for its worker. |
191 | 197 | await writeFile(`${dirname(connectionFile)}.removal.json`, JSON.stringify({ pid: process.pid })); |
| 198 | + // npm exec can unpack a local tarball again on every invocation. Isolate its |
| 199 | + // caches so this tests competing profile removals rather than npm extraction races. |
192 | 200 | await Promise.all([ |
193 | | - command(['npx', '--yes', packageSpec, 'remove', '--wait']), |
194 | | - command(['npx', '--yes', packageSpec, 'remove', '--wait']), |
| 201 | + command({ |
| 202 | + args: ['npx', '--yes', packageSpec, 'remove', '--wait'], |
| 203 | + env: { npm_config_cache: join(directory, 'removal-first-npm-cache') }, |
| 204 | + }), |
| 205 | + command({ |
| 206 | + args: ['npx', '--yes', packageSpec, 'remove', '--wait'], |
| 207 | + env: { npm_config_cache: join(directory, 'removal-second-npm-cache') }, |
| 208 | + }), |
195 | 209 | ]); |
196 | 210 | assert(!(await Bun.file(connectionFile).exists()), 'Cancelled setup retained credentials'); |
197 | 211 | assert(!existsSync(dirname(connectionFile)), 'Removal retained its private directory'); |
198 | 212 | await command(['npx', '--yes', packageSpec, 'remove', '--wait']); |
199 | 213 | assert((await command(['npx', '--yes', packageSpec, 'status'])).includes('"installed": false')); |
200 | 214 | assert(!existsSync(dirname(connectionFile)), 'Status recreated removed state'); |
201 | | - assert(!(await configuration()).plugins.entries?.[PLUGIN_ID]); |
| 215 | + assert.deepEqual((await configuration()).plugins.entries?.[PLUGIN_ID], { enabled: false }); |
202 | 216 | await command(['npx', '--yes', packageSpec, 'connect', app.origin]); |
203 | 217 | const pending = await Bun.file(connectionFile).json(); |
204 | 218 | console.log('Removed an unfinished installation through the npm helper and connected again.'); |
@@ -427,7 +441,7 @@ config.models={providers:{fixture:{baseUrl:${JSON.stringify(`${model.origin}/v1` |
427 | 441 | ); |
428 | 442 | await command(['npx', '--yes', packageSpec, 'remove', '--wait']); |
429 | 443 | const afterRemoval = await configuration(); |
430 | | - assert(!afterRemoval.plugins.entries?.[PLUGIN_ID], 'Native uninstall tombstone survived'); |
| 444 | + assert.deepEqual(afterRemoval.plugins.entries?.[PLUGIN_ID], { enabled: false }); |
431 | 445 | assert.deepEqual(afterRemoval.agents.entries.main.tools.alsoAllow, ['web_search', 'read']); |
432 | 446 | assert.equal(await Bun.file(join(workspace, 'AGENTS.md')).text(), editedAgents); |
433 | 447 | assert.equal( |
@@ -759,6 +773,46 @@ config.models={providers:{fixture:{baseUrl:${JSON.stringify(`${model.origin}/v1` |
759 | 773 | 'Removal lease survived cleanup', |
760 | 774 | ); |
761 | 775 | console.log('The npm helper refreshed the gateway after uninstall.'); |
| 776 | + // Recovery must also work for interrupted installs whose files were removed |
| 777 | + // externally, even when this shell cannot authenticate to the running gateway. |
| 778 | + await command(['npx', '--yes', packageSpec, 'connect', app.origin]); |
| 779 | + const interruptedInventory = JSON.parse(await command(['openclaw', 'plugins', 'list', '--json'])); |
| 780 | + const interruptedPlugin = interruptedInventory.plugins.find( |
| 781 | + (plugin: { id: string }) => plugin.id === PLUGIN_ID, |
| 782 | + ); |
| 783 | + assert(interruptedPlugin, 'Unfinished reinstall did not install the package'); |
| 784 | + const packageRoot = await realpath(interruptedPlugin.rootDir); |
| 785 | + const fixtureRoot = await realpath(directory); |
| 786 | + const packagePath = relative(fixtureRoot, packageRoot); |
| 787 | + assert(packagePath && !packagePath.startsWith('..') && !isAbsolute(packagePath)); |
| 788 | + await rm(packageRoot, { recursive: true, force: true }); |
| 789 | + const invalidGatewayAuth = { |
| 790 | + OPENCLAW_GATEWAY_URL: `ws://127.0.0.1:${gatewayPort}`, |
| 791 | + OPENCLAW_GATEWAY_TOKEN: 'invalid-removal-test-token', |
| 792 | + }; |
| 793 | + await assert.rejects( |
| 794 | + command({ |
| 795 | + args: ['openclaw', 'gateway', 'call', 'config.get', '--timeout', '3000', '--json'], |
| 796 | + env: invalidGatewayAuth, |
| 797 | + }), |
| 798 | + ); |
| 799 | + await command({ |
| 800 | + args: ['npx', '--yes', packageSpec, 'remove', '--wait'], |
| 801 | + env: invalidGatewayAuth, |
| 802 | + }); |
| 803 | + const recovered = await configuration(); |
| 804 | + assert.deepEqual(recovered.plugins.entries?.[PLUGIN_ID], { enabled: false }); |
| 805 | + assert.notEqual(recovered.plugins.slots?.memory, PLUGIN_ID); |
| 806 | + assert(!existsSync(dirname(connectionFile)), 'Orphaned private state survived cleanup'); |
| 807 | + assert( |
| 808 | + !existsSync(`${dirname(connectionFile)}.removal.json`), |
| 809 | + 'Gateway authorization failure blocked removal', |
| 810 | + ); |
| 811 | + await command(['npx', '--yes', packageSpec, 'remove', '--wait']); |
| 812 | + assert((await command(['npx', '--yes', packageSpec, 'status'])).includes('"installed": false')); |
| 813 | + console.log( |
| 814 | + 'Recovered a partially deleted reinstall without gateway authorization, and repeated removal without leftovers.', |
| 815 | + ); |
762 | 816 | } finally { |
763 | 817 | try { |
764 | 818 | await owner?.close(); |
|
0 commit comments