Skip to content

Commit c09dbc6

Browse files
thymikeeclaude
andcommitted
feat(provider-testmu): publish @agent-device/testmu on plugin contract v2
The package default-exports testmu(options) at ./agent-device and the root. Credentials come from options with LT_USERNAME and LT_ACCESS_KEY as the fallback. The v1 agentDevicePlugin declaration and its entry stay, so the package still loads on hosts without the v2 contract. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1 parent 2d8cb1a commit c09dbc6

12 files changed

Lines changed: 252 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract.
56
- Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading.
67
- Bundled providers, installed plugins, and embedder plugins now register through one registry, in that order. The first plugin to name a provider id claims it; a later duplicate is recorded as failed, and `cloud` and `proxy` stay reserved.
78
- A plugin that fails to load, validate, or set up no longer stops the daemon. It is listed as `failed` with a typed `error.reason`, and the other providers still register. The new `agent-device daemon status [--json]` shows the inventory, which the daemon also publishes in `daemon.json`; `plugins list --json` works without a daemon and reports a running daemon's inventory under `daemon`.

‎packages/provider-testmu/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,15 @@ See the [TestMu AI guide](https://oss.callstack.com/agent-device/docs/testmu) fo
1515
To update the plugin, run `agent-device plugins update @agent-device/testmu`.
1616

1717
After you add or update the plugin, close your sessions and run `agent-device daemon stop` before reconnecting.
18+
19+
## Use it from JavaScript
20+
21+
The package default-exports a plugin factory. Pass credentials as options, or leave them out to read `LT_USERNAME` and `LT_ACCESS_KEY` from the environment:
22+
23+
```js
24+
import testmu from '@agent-device/testmu';
25+
26+
const plugin = testmu({ credentials: { username: 'your-username', accessKey: 'your-access-key' } });
27+
```
28+
29+
Credentials passed as options are used instead of the environment variables, and the daemon does not compare them with your shell.

‎packages/provider-testmu/package.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"types": "./src/index.ts",
1616
"default": "./src/index.ts"
1717
},
18+
"./agent-device": {
19+
"types": "./src/index.ts",
20+
"default": "./src/index.ts"
21+
},
1822
"./connection-verification": {
1923
"types": "./src/testmu-connection-verification.ts",
2024
"default": "./src/testmu-connection-verification.ts"
@@ -48,7 +52,10 @@
4852
"publishConfig": {
4953
"exports": {
5054
".": {
51-
"import": "./dist/plugin.mjs"
55+
"import": "./dist/agent-device.mjs"
56+
},
57+
"./agent-device": {
58+
"import": "./dist/agent-device.mjs"
5259
}
5360
},
5461
"access": "public"

‎packages/provider-testmu/src/connection.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ import {
1919
import { verifyTestMuConnection } from './testmu-connection-verification.ts';
2020
import { readTestMuDeviceFeatureFields, readTestMuDeviceType } from './testmu-device-features.ts';
2121

22+
/** Mirrors `agentDevicePlugin.connection` in package.json, which hosts without the version 2 contract read. */
23+
export const TESTMU_CONNECTION = {
24+
leaseKind: 'direct-device-provider',
25+
requiresAppAttachment: false,
26+
requiresRemoteDaemon: false,
27+
supportsArtifacts: true,
28+
supportsDeferredAppSelection: false,
29+
supportsDirectPortReverse: false,
30+
usesCloudWebDriverLease: true,
31+
} as const;
32+
2233
const PROVIDER = 'testmu';
2334
const SERVICE = 'TestMu AI';
2435

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, test, vi } from 'vitest';
3+
import type { ProviderPluginHost } from 'agent-device/plugins';
4+
import { AppError } from '@agent-device/kernel/errors';
5+
import testmu from './index.ts';
6+
import { verifyTestMuConnection } from './testmu-connection-verification.ts';
7+
import manifest from '../package.json' with { type: 'json' };
8+
9+
vi.mock('./testmu-connection-verification.ts', () => ({ verifyTestMuConnection: vi.fn() }));
10+
11+
const realFetch = globalThis.fetch;
12+
13+
afterEach(() => {
14+
globalThis.fetch = realFetch;
15+
vi.clearAllMocks();
16+
});
17+
18+
function host(env: Record<string, string | undefined>): ProviderPluginHost {
19+
return {
20+
env,
21+
options: {},
22+
clientVersion: '0.0.0-test',
23+
createError: (code, message, details) => new AppError(code, message, details),
24+
};
25+
}
26+
27+
function capturedAuthorization(): { read(): string | undefined } {
28+
let authorization: string | undefined;
29+
globalThis.fetch = async (_input, init) => {
30+
authorization = new Headers(init?.headers).get('authorization') ?? undefined;
31+
return new Response(JSON.stringify({ data: {} }));
32+
};
33+
return { read: () => authorization };
34+
}
35+
36+
async function listArtifacts(plugin: ReturnType<typeof testmu>, env: Record<string, string>) {
37+
const setup = await plugin.setup(host(env));
38+
if (!('webDriver' in setup)) throw new Error('TestMu AI sets up a WebDriver descriptor');
39+
await setup.webDriver.listArtifacts!({ provider: 'testmu', providerSessionId: 'SESSION1' });
40+
}
41+
42+
const basic = (username: string, accessKey: string) =>
43+
`Basic ${Buffer.from(`${username}:${accessKey}`).toString('base64')}`;
44+
45+
test('the plugin declares the LT variables only when it reads credentials from the environment', () => {
46+
assert.deepEqual(testmu().credentials, { variables: ['LT_USERNAME', 'LT_ACCESS_KEY'] });
47+
assert.deepEqual(testmu({ credentials: { username: 'u', accessKey: 'k' } }).credentials, {
48+
variables: [],
49+
});
50+
});
51+
52+
test('credentials passed as options replace the environment, which stays the fallback', async () => {
53+
const captured = capturedAuthorization();
54+
const env = { LT_USERNAME: 'env-user', LT_ACCESS_KEY: 'env-key' };
55+
56+
await listArtifacts(testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }), env);
57+
assert.equal(captured.read(), basic('opt-user', 'opt-key'));
58+
await listArtifacts(testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }), {});
59+
assert.equal(captured.read(), basic('opt-user', 'opt-key'));
60+
await listArtifacts(testmu(), env);
61+
assert.equal(captured.read(), basic('env-user', 'env-key'));
62+
await assert.rejects(listArtifacts(testmu(), {}), { code: 'INVALID_ARGS' });
63+
});
64+
65+
test('connect uses the option credentials without an environment and verifies with them', async () => {
66+
vi.mocked(verifyTestMuConnection).mockResolvedValue({
67+
provider: 'testmu',
68+
service: 'TestMu AI',
69+
verificationMessage: 'verified',
70+
device: { status: 'verified', name: 'iPhone 16', platform: 'ios', osVersion: '18.0' },
71+
app: { status: 'verified', reference: 'lt://APP1' },
72+
});
73+
const { connection } = testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } });
74+
const flags = {
75+
json: false,
76+
help: false,
77+
version: false,
78+
platform: 'ios',
79+
device: 'iPhone 16',
80+
providerOsVersion: '18.0',
81+
providerApp: 'lt://APP1',
82+
} as const;
83+
84+
const resolved = await connection!.resolve({
85+
flags,
86+
stateDir: '/state',
87+
cwd: '/work',
88+
env: {},
89+
host: host({}),
90+
});
91+
assert.equal(resolved.profile.leaseProvider, 'testmu');
92+
await connection!.verify({ flags, env: {}, host: host({}) });
93+
assert.deepEqual(
94+
[
95+
vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].username,
96+
vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].accessKey,
97+
],
98+
['opt-user', 'opt-key'],
99+
);
100+
assert.throws(
101+
() =>
102+
testmu().connection!.resolve({
103+
flags,
104+
stateDir: '/state',
105+
cwd: '/work',
106+
env: {},
107+
host: host({}),
108+
}),
109+
{ code: 'INVALID_ARGS' },
110+
);
111+
});
112+
113+
test('the v2 declaration matches the v1 manifest that hosts without the v2 contract read', () => {
114+
const v1 = manifest.agentDevicePlugin;
115+
assert.equal(testmu().provider, v1.provider);
116+
assert.deepEqual(testmu().connection?.capabilities, v1.connection);
117+
assert.deepEqual(testmu().credentials.variables, v1.credentialVariables);
118+
assert.equal(v1.apiVersion, 1);
119+
});
120+
121+
test('a credentials object missing one key falls back to the environment for that key', async () => {
122+
const captured = capturedAuthorization();
123+
await listArtifacts(testmu({ credentials: { username: 'opt-user' } as never }), {
124+
LT_USERNAME: 'env-user',
125+
LT_ACCESS_KEY: 'env-key',
126+
});
127+
assert.equal(captured.read(), basic('opt-user', 'env-key'));
128+
});
Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,44 @@
1-
export { default } from './plugin.ts';
1+
import type { AgentDevicePlugin, ProviderPluginHost } from 'agent-device/plugins';
2+
import { TESTMU_CONNECTION } from './connection.ts';
3+
import testMuV1Plugin from './plugin.ts';
4+
5+
export type TestmuOptions = {
6+
/** Used instead of `LT_USERNAME` and `LT_ACCESS_KEY` when set. */
7+
credentials?: { username: string; accessKey: string };
8+
};
9+
10+
/**
11+
* TestMu AI as a plugin. The credentials come from `options.credentials`, with the
12+
* `LT_USERNAME` and `LT_ACCESS_KEY` environment variables as the fallback; credentials passed as
13+
* options are not part of the environment the daemon fingerprints.
14+
*/
15+
function testmu(options: TestmuOptions = {}): AgentDevicePlugin {
16+
const { credentials } = options;
17+
const withCredentials = (host: ProviderPluginHost): ProviderPluginHost =>
18+
credentials
19+
? {
20+
...host,
21+
// A key the caller left out falls back to the environment instead of shadowing it.
22+
env: {
23+
...host.env,
24+
...(credentials.username ? { LT_USERNAME: credentials.username } : {}),
25+
...(credentials.accessKey ? { LT_ACCESS_KEY: credentials.accessKey } : {}),
26+
},
27+
}
28+
: host;
29+
return {
30+
apiVersion: 2,
31+
provider: 'testmu',
32+
credentials: { variables: credentials ? [] : ['LT_USERNAME', 'LT_ACCESS_KEY'] },
33+
connection: {
34+
capabilities: TESTMU_CONNECTION,
35+
resolve: (context) =>
36+
testMuV1Plugin(withCredentials(context.host)).connection.resolve(context),
37+
verify: async (context) =>
38+
await testMuV1Plugin(withCredentials(context.host)).connection.verify(context),
39+
},
40+
setup: (host) => ({ webDriver: testMuV1Plugin(withCredentials(host)).webDriver }),
41+
};
42+
}
43+
44+
export { testmu as default };

‎packages/provider-testmu/src/plugin.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,15 @@ const TESTMU_PROFILE_FIELDS: ProviderProfileFieldDeclaration = {
6565
},
6666
};
6767

68+
/** The version 1 plugin entry, kept so hosts without the version 2 contract still load this package. */
6869
export default function testMuPlugin(host: ProviderPluginHost) {
70+
return {
71+
webDriver: createTestMuWebDriver(host),
72+
connection: createTestMuConnection(host, TESTMU_PROFILE_FIELDS),
73+
};
74+
}
75+
76+
function createTestMuWebDriver(host: ProviderPluginHost): WebDriverPluginOptions {
6977
const env = host.env;
7078
async function listTestMuArtifactsFromEnv(
7179
provider: string,
@@ -137,7 +145,7 @@ export default function testMuPlugin(host: ProviderPluginHost) {
137145
};
138146
},
139147
};
140-
return { webDriver, connection: createTestMuConnection(host, TESTMU_PROFILE_FIELDS) };
148+
return webDriver;
141149
}
142150
/** Each pool has its own upload API, so each has its own override. */
143151
function testMuAppUploadEndpoint(

‎packages/provider-testmu/tsdown.config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defineConfig } from 'tsdown';
22

33
export default defineConfig({
4-
entry: { plugin: 'src/plugin.ts' },
4+
entry: { plugin: 'src/plugin.ts', 'agent-device': 'src/index.ts' },
55
outDir: 'dist',
66
format: 'esm',
77
platform: 'node',

‎scripts/check-provider-plugin.mjs‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,15 @@ async function pack(directory) {
4747
}
4848
try {
4949
await run('pnpm', ['build'], plugin);
50-
const coreTarball = await pack(root);
50+
// AGENT_DEVICE_CORE_SPEC installs a published agent-device (such as [email protected]) instead
51+
// of packing this checkout, to prove a plugin still loads on a host without the newest contract.
52+
const coreSpec = process.env.AGENT_DEVICE_CORE_SPEC ?? (await pack(root));
5153
const pluginTarball = await pack(plugin);
5254
for (const directory of [consumer, project]) {
5355
await fs.mkdir(directory, { recursive: true });
5456
await fs.writeFile(path.join(directory, 'package.json'), '{"private":true,"type":"module"}');
5557
}
56-
await run(
57-
'npm',
58-
['install', '--ignore-scripts', '--no-audit', '--no-fund', coreTarball],
59-
consumer,
60-
);
58+
await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', coreSpec], consumer);
6159
await run(
6260
'npm',
6361
['install', '--ignore-scripts', '--no-audit', '--no-fund', pluginTarball],
@@ -67,7 +65,10 @@ try {
6765
const installed = JSON.parse(
6866
await fs.readFile(path.join(project, 'node_modules', manifest.name, 'package.json'), 'utf8'),
6967
);
70-
assert.deepEqual(installed.exports, { '.': { import: './dist/plugin.mjs' } });
68+
assert.deepEqual(installed.exports, {
69+
'.': { import: './dist/agent-device.mjs' },
70+
'./agent-device': { import: './dist/agent-device.mjs' },
71+
});
7172
assert.equal(installed.dependencies, undefined);
7273
assert.equal(installed.peerDependencies, undefined);
7374
await assert.rejects(fs.stat(path.join(project, 'node_modules', 'agent-device')), {

‎src/__tests__/cloud-connect-testmu.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
} from '@agent-device/remote/remote-connection-state';
1010
import { AppError } from '@agent-device/kernel/errors';
1111
import { verifyTestMuConnection } from '@agent-device/testmu/connection-verification';
12-
import testMuPlugin from '@agent-device/testmu';
13-
import { createPluginHost } from '@agent-device/plugins/host';
12+
import testmu from '@agent-device/testmu';
13+
import { resolveProviderPlugins } from '@agent-device/plugins/registry';
1414
import { resolveConnectProviderProfile } from '../cli/connection/connect-provider-adapters.ts';
1515
import { mkdtempForTestSync, pluginHome, selectPlugin } from '@agent-device/test-kit';
1616
import manifest from '@agent-device/testmu/package.json' with { type: 'json' };
@@ -24,11 +24,13 @@ vi.mock('@agent-device/plugins/load', () => ({
2424
withPluginConnection: async (
2525
_provider: string,
2626
_builtinProviders: readonly string[],
27-
env: NodeJS.ProcessEnv,
27+
_env: NodeJS.ProcessEnv,
2828
runConnection: (connection: PluginConnectionCallbacks) => Promise<unknown>,
2929
) => {
30-
const registration = testMuPlugin(createPluginHost(env, undefined));
31-
return await runConnection(registration.connection);
30+
const { connections } = await resolveProviderPlugins([
31+
{ source: 'store', package: manifest.name, load: () => testmu() },
32+
]);
33+
return await runConnection(connections.get('testmu')!);
3234
},
3335
}));
3436

0 commit comments

Comments
 (0)