Files
callstack__agent-device/test/integration/remote-connection-process-isolation.test.ts
Michał Pierzchała e6b4fa2810 fix: isolate concurrent remote connections (#1675)
* fix: isolate concurrent remote connections

* refactor: harden remote connection state

* fix(cli): scope every emitted connect command to its own session

`scopeNextSteps` only reached `ConnectReadiness.nextSteps`, so two
command-bearing outputs still shipped unscoped:

- `providerArtifactNotes()` emitted `agent-device artifacts --json` as a
  prose note, which never passes through that helper.
- `buildDeferredRuntimeNotice()` emitted `agent-device metro prepare
  --remote-config <path>` independently in connection.ts.

On the shared-host concurrency path this branch fixes, following either
one resolves against the host-global active connection, so the artifacts
instruction can return another job's provider video and log URLs (#1659).

Both producers now take the connection state and format through one
exported `scopeCommand` helper, which is the single place a suggested
command is bound to its originating session. The metro config path is
shell-quoted alongside the session name.

Coverage: the BrowserStack route asserts human and JSON shapes carry one
`--session` per suggested command and that each emitted session resolves
back through `readRemoteConnectionState` to the connection that printed
it; `connection status` pins the scoped deferred-metro `nextStep`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 07:54:43 +02:00

72 lines
2.3 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts';
import { readRemoteConnectionState } from '../../src/remote/remote-connection-state.ts';
import { runSourceCliJsonSync } from './cli-json.ts';
test('separate CLI processes do not adopt and overwrite the ambient remote session', () => {
const root = mkdtempForTestSync('agent-device-connect-process-isolation-');
const stateDir = path.join(root, 'state');
const firstConfig = writeRemoteConfig(root, 'first', 'iPhone 15 Pro', '17');
const secondConfig = writeRemoteConfig(root, 'second', 'iPad Pro 11 2025', '26');
const first = connectInSeparateProcess(stateDir, firstConfig);
const second = connectInSeparateProcess(stateDir, secondConfig);
assert.equal(first.status, 0, first.stderr);
assert.equal(second.status, 0, second.stderr);
const firstSession = readConnectedSession(first.json);
const secondSession = readConnectedSession(second.json);
assert.notEqual(secondSession, firstSession);
assert.equal(
readRemoteConnectionState({ stateDir, session: firstSession })?.remoteConfigPath,
firstConfig,
);
assert.equal(
readRemoteConnectionState({ stateDir, session: secondSession })?.remoteConfigPath,
secondConfig,
);
});
function writeRemoteConfig(
root: string,
runId: string,
device: string,
providerOsVersion: string,
): string {
const configPath = path.join(root, `${runId}.remote.json`);
fs.writeFileSync(
configPath,
`${JSON.stringify({
daemonBaseUrl: 'https://daemon.example.test/agent-device',
tenant: 'browserstack',
runId,
sessionIsolation: 'tenant',
platform: 'ios',
device,
providerOsVersion,
})}\n`,
);
return configPath;
}
function connectInSeparateProcess(stateDir: string, remoteConfig: string) {
return runSourceCliJsonSync([
'connect',
'--remote-config',
remoteConfig,
'--state-dir',
stateDir,
'--force',
'--json',
]);
}
function readConnectedSession(output: unknown): string {
const session = (output as { data?: { session?: unknown } } | undefined)?.data?.session;
assert.ok(typeof session === 'string');
return session;
}