Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/__tests__/cli-react-devtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock('../client/client-react-devtools-companion.ts', () => ({
}));

import { runCmdStreaming } from '@agent-device/host-kit/command';
import { AppError } from '@agent-device/kernel/errors';
import { withCapturedStderrAsync } from './test-utils/stream-capture.ts';
import {
ensureReactDevtoolsCompanion,
Expand Down Expand Up @@ -228,6 +229,51 @@ test('react-devtools stop cleans up remote companion', async () => {
});
});

// What the companion stop answers decides the verdict of `react-devtools stop`: an unconfirmed exit
// is reported instead of a clean teardown, and the command's own fault still outranks it.
const runRemoteStop = () =>
runReactDevtoolsCommand(['stop'], {
cwd: '/tmp/project',
flags: { ...remoteBridgeScope, leaseBackend: 'ios-instance', remoteConfig: '/tmp/remote.json' },
});

test('react-devtools stop reports a companion whose stop could not be confirmed', async () => {
const refusal = new AppError('COMMAND_FAILED', 'companion pid 4321 is still running', {
reason: 'signal-failed',
signal: 'SIGTERM',
pid: 4321,
});
vi.mocked(runCmdStreaming).mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });
vi.mocked(stopReactDevtoolsCompanion).mockRejectedValueOnce(refusal);

await assert.rejects(runRemoteStop, refusal);
});

test('react-devtools stop keeps its own failure ahead of an unconfirmed companion stop', async () => {
const actionFault = new Error('devtools package failed');
const stopFault = new AppError('COMMAND_FAILED', 'companion pid 4322 is still running', {
reason: 'signal-failed',
pid: 4322,
});
vi.mocked(runCmdStreaming).mockRejectedValueOnce(actionFault);
vi.mocked(stopReactDevtoolsCompanion).mockRejectedValueOnce(stopFault);

const stderr = await withCapturedStderrAsync(() => assert.rejects(runRemoteStop(), actionFault));
assert.equal(vi.mocked(stopReactDevtoolsCompanion).mock.calls.length, 1, 'cleanup still ran');
// The action's fault is what the command reports, so the surviving companion has to be named here.
assert.match(stderr, /companion pid 4322 is still running/);
});

// The stop's own transport can reject with anything, and an unconfirmed exit may only ever be
// reported, never swallowed: the command fails on the rejection value itself, not on its shape.
test('react-devtools stop fails on a stop rejection that is not an Error', async () => {
vi.mocked(runCmdStreaming).mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' });
vi.mocked(stopReactDevtoolsCompanion).mockRejectedValueOnce('worker exited before answering');

const settled = await runRemoteStop().catch((error: unknown) => error);
assert.equal(settled, 'worker exited before answering');
});

test('react-devtools start configures direct remote port reverse', async () => {
const configureDirectPortReverse = vi.fn(async () => undefined);
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
Expand Down
33 changes: 23 additions & 10 deletions src/cli/commands/react-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
ensureReactDevtoolsCompanion,
stopReactDevtoolsCompanion,
} from '../../client/client-react-devtools-companion.ts';
import { AppError } from '@agent-device/kernel/errors';
import { AppError, errorMessage } from '@agent-device/kernel/errors';
import { isRemoteBridgeBackend } from './remote-bridge.ts';
import type { CliFlags } from '@agent-device/contracts/command';
import { connectionProviderCapabilities } from '../connection/provider-policy.ts';
Expand Down Expand Up @@ -131,16 +131,29 @@ async function withRemoteDevtoolsCompanion<T>(
flags?.remoteConfig ?? `${bridgeConfig.tenantId}:${bridgeConfig.runId}:${bridgeConfig.leaseId}`;

if (args[0] === 'stop') {
try {
return await action();
} finally {
await stopReactDevtoolsCompanion({
projectRoot: options.cwd ?? process.cwd(),
stateDir,
profileKey,
consumerKey: session,
});
// An unconfirmed companion stop fails this command, except when the action failed too: then the
// action's fault is the verdict and the surviving companion is named on stderr beside it.
const acted = await action().then(
(value) => ({ value }) as const,
(error: unknown) => ({ error }) as const,
);
const stopped = await stopReactDevtoolsCompanion({
projectRoot: options.cwd ?? process.cwd(),
stateDir,
profileKey,
consumerKey: session,
}).then(
() => ({}) as const,
(error: unknown) => ({ error }) as const,
);
if ('error' in acted) {
if ('error' in stopped) {
process.stderr.write(`Warning: ${errorMessage(stopped.error)}\n`);
}
throw acted.error;
}
if ('error' in stopped) throw stopped.error;
return acted.value;
}

await ensureReactDevtoolsCompanion({
Expand Down
Loading