Skip to content

Commit 8e42f2e

Browse files
authored
fix: harden public API boundaries (#399)
1 parent 406b3ed commit 8e42f2e

18 files changed

Lines changed: 273 additions & 218 deletions

package.json

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@
1212
"import": "./dist/src/index.js",
1313
"types": "./dist/src/index.d.ts"
1414
},
15-
"./artifacts": {
16-
"import": "./dist/src/artifacts.js",
17-
"types": "./dist/src/artifacts.d.ts"
18-
},
1915
"./metro": {
2016
"import": "./dist/src/metro.js",
2117
"types": "./dist/src/metro.d.ts"
@@ -24,21 +20,9 @@
2420
"import": "./dist/src/remote-config.js",
2521
"types": "./dist/src/remote-config.d.ts"
2622
},
27-
"./install-source": {
28-
"import": "./dist/src/install-source.js",
29-
"types": "./dist/src/install-source.d.ts"
30-
},
3123
"./contracts": {
3224
"import": "./dist/src/contracts.js",
3325
"types": "./dist/src/contracts.d.ts"
34-
},
35-
"./selectors": {
36-
"import": "./dist/src/selectors.js",
37-
"types": "./dist/src/selectors.d.ts"
38-
},
39-
"./finders": {
40-
"import": "./dist/src/finders.js",
41-
"types": "./dist/src/finders.d.ts"
4226
}
4327
},
4428
"engines": {

rslib.config.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,10 @@ export default defineConfig({
1616
},
1717
source: {
1818
entry: {
19-
artifacts: 'src/artifacts.ts',
2019
index: 'src/index.ts',
21-
'install-source': 'src/install-source.ts',
2220
metro: 'src/metro.ts',
2321
'remote-config': 'src/remote-config.ts',
2422
contracts: 'src/contracts.ts',
25-
selectors: 'src/selectors.ts',
26-
finders: 'src/finders.ts',
2723
},
2824
tsconfigPath: 'tsconfig.lib.json',
2925
},

src/__tests__/artifacts-public.test.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

src/__tests__/client-metro-auto-companion.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,100 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan
257257
fs.rmSync(tempRoot, { recursive: true, force: true });
258258
}
259259
});
260+
261+
test('prepareMetroRuntime retries malformed retryable bridge responses after companion startup', async () => {
262+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-html-'));
263+
const projectRoot = path.join(tempRoot, 'project');
264+
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
265+
fs.writeFileSync(
266+
path.join(projectRoot, 'package.json'),
267+
JSON.stringify({
268+
name: 'metro-auto-companion-html-test',
269+
private: true,
270+
dependencies: {
271+
'react-native': '0.0.0-test',
272+
},
273+
}),
274+
);
275+
276+
vi.mocked(ensureMetroCompanion).mockResolvedValue({
277+
pid: 123,
278+
spawned: true,
279+
statePath: path.join(projectRoot, '.agent-device', 'metro-companion.json'),
280+
logPath: path.join(projectRoot, '.agent-device', 'metro-companion.log'),
281+
});
282+
283+
const fetchMock = vi.fn();
284+
fetchMock.mockResolvedValueOnce({
285+
ok: true,
286+
status: 200,
287+
text: async () => 'packager-status:running',
288+
});
289+
fetchMock.mockResolvedValueOnce({
290+
ok: false,
291+
status: 409,
292+
text: async () => JSON.stringify({ ok: false, error: 'Metro companion is not connected' }),
293+
});
294+
fetchMock.mockResolvedValueOnce({
295+
ok: false,
296+
status: 503,
297+
text: async () => '<html>upstream unavailable</html>',
298+
});
299+
fetchMock.mockResolvedValueOnce({
300+
ok: true,
301+
status: 200,
302+
text: async () =>
303+
JSON.stringify({
304+
ok: true,
305+
data: {
306+
enabled: true,
307+
base_url: 'https://proxy.example.test',
308+
status_url: 'https://proxy.example.test/status',
309+
bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
310+
ios_runtime: {
311+
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
312+
},
313+
android_runtime: {
314+
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android',
315+
},
316+
upstream: {
317+
bundle_url:
318+
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
319+
},
320+
probe: {
321+
reachable: true,
322+
status_code: 200,
323+
latency_ms: 5,
324+
detail: 'ok',
325+
},
326+
},
327+
}),
328+
});
329+
vi.stubGlobal('fetch', fetchMock);
330+
vi.useFakeTimers();
331+
332+
try {
333+
const preparePromise = prepareMetroRuntime({
334+
projectRoot,
335+
publicBaseUrl: 'https://public.example.test',
336+
proxyBaseUrl: 'https://proxy.example.test',
337+
proxyBearerToken: 'shared-token',
338+
metroPort: 8081,
339+
reuseExisting: true,
340+
installDependenciesIfNeeded: false,
341+
probeTimeoutMs: 10,
342+
});
343+
344+
await vi.advanceTimersByTimeAsync(1_000);
345+
const result = await preparePromise;
346+
347+
assert.equal(result.bridge?.enabled, true);
348+
assert.equal(
349+
result.iosRuntime.bundleUrl,
350+
'https://proxy.example.test/index.bundle?platform=ios',
351+
);
352+
assert.equal(fetchMock.mock.calls.length, 4);
353+
} finally {
354+
fs.rmSync(tempRoot, { recursive: true, force: true });
355+
}
356+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterEach, test, vi } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
7+
vi.mock('../utils/exec.ts', () => ({
8+
runCmdDetached: vi.fn(),
9+
runCmdSync: vi.fn(),
10+
}));
11+
12+
vi.mock('../utils/process-identity.ts', () => ({
13+
waitForProcessExit: vi.fn(),
14+
}));
15+
16+
import { runCmdDetached } from '../utils/exec.ts';
17+
import { waitForProcessExit } from '../utils/process-identity.ts';
18+
import { prepareMetroRuntime } from '../client-metro.ts';
19+
20+
afterEach(() => {
21+
vi.useRealTimers();
22+
vi.clearAllMocks();
23+
vi.restoreAllMocks();
24+
vi.unstubAllGlobals();
25+
});
26+
27+
test('prepareMetroRuntime stops a spawned Metro process when startup readiness times out', async () => {
28+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-startup-cleanup-'));
29+
const projectRoot = path.join(tempRoot, 'project');
30+
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
31+
fs.writeFileSync(
32+
path.join(projectRoot, 'package.json'),
33+
JSON.stringify({
34+
name: 'metro-cleanup-test',
35+
private: true,
36+
dependencies: {
37+
'react-native': '0.0.0-test',
38+
},
39+
}),
40+
);
41+
42+
vi.mocked(runCmdDetached).mockReturnValue(987);
43+
vi.mocked(waitForProcessExit).mockResolvedValue(true);
44+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
45+
const fetchMock = vi.fn().mockResolvedValue({
46+
ok: true,
47+
status: 200,
48+
text: async () => 'packager-status:not-running',
49+
});
50+
vi.stubGlobal('fetch', fetchMock);
51+
vi.useFakeTimers();
52+
53+
try {
54+
const preparePromise = prepareMetroRuntime({
55+
projectRoot,
56+
publicBaseUrl: 'https://public.example.test',
57+
metroPort: 8081,
58+
reuseExisting: true,
59+
installDependenciesIfNeeded: false,
60+
probeTimeoutMs: 10,
61+
startupTimeoutMs: 30_000,
62+
});
63+
64+
const expectedFailure = assert.rejects(
65+
preparePromise,
66+
/Metro did not become ready at http:\/\/127\.0\.0\.1:8081\/status within 30000ms/,
67+
);
68+
await vi.advanceTimersByTimeAsync(30_000);
69+
await expectedFailure;
70+
assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1);
71+
assert.deepEqual(killSpy.mock.calls[0], [987, 'SIGTERM']);
72+
} finally {
73+
fs.rmSync(tempRoot, { recursive: true, force: true });
74+
}
75+
});

src/__tests__/client-public.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type AgentDeviceClient,
66
type CaptureScreenshotResult,
77
type CaptureSnapshotResult,
8+
type AgentDeviceDaemonTransport,
89
centerOfRect,
910
type Point,
1011
type Rect,
@@ -13,6 +14,7 @@ import {
1314
type SnapshotVisibility,
1415
type SnapshotVisibilityReason,
1516
} from '../index.ts';
17+
import type { DaemonRequest, DaemonResponse } from '../contracts.ts';
1618

1719
const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect;
1820
const point = { x: 2, y: 4 } satisfies Point;
@@ -58,3 +60,17 @@ test('package root exports createAgentDeviceClient', () => {
5860
assert.equal(typeof client.capture.snapshot, 'function');
5961
assert.deepEqual(centerOfRect(rect), { x: 3, y: 4 });
6062
});
63+
64+
test('public daemon transport is typed against public daemon contracts', async () => {
65+
const transport: AgentDeviceDaemonTransport = async (
66+
request: Omit<DaemonRequest, 'token'>,
67+
): Promise<DaemonResponse> => ({
68+
ok: true,
69+
data: {
70+
command: request.command,
71+
},
72+
});
73+
const response = await transport({ command: 'devices', positionals: [] });
74+
75+
assert.equal(response.ok, true);
76+
});

src/__tests__/client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
66
import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts';
7-
import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts';
7+
import type { DaemonRequest, DaemonResponse } from '../contracts.ts';
88
import { AppError } from '../utils/errors.ts';
99

1010
function createTransport(

src/__tests__/finders-public.test.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/__tests__/selectors-public.test.ts

Lines changed: 0 additions & 70 deletions
This file was deleted.

src/artifacts.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)