lim 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/JSON_OUTPUT.md +123 -0
  2. package/README.md +18 -2
  3. package/bin/dev.js +8 -3
  4. package/bin/json-errors.cjs +35 -0
  5. package/bin/run.js +3 -0
  6. package/dist/base-command.d.ts +5 -1
  7. package/dist/base-command.d.ts.map +1 -1
  8. package/dist/base-command.js +43 -11
  9. package/dist/base-command.js.map +1 -1
  10. package/dist/commands/android/app-log.d.ts.map +1 -1
  11. package/dist/commands/android/app-log.js +10 -1
  12. package/dist/commands/android/app-log.js.map +1 -1
  13. package/dist/commands/android/camera.d.ts.map +1 -1
  14. package/dist/commands/android/camera.js +8 -2
  15. package/dist/commands/android/camera.js.map +1 -1
  16. package/dist/commands/android/connect.d.ts.map +1 -1
  17. package/dist/commands/android/connect.js +2 -2
  18. package/dist/commands/android/connect.js.map +1 -1
  19. package/dist/commands/android/create.d.ts.map +1 -1
  20. package/dist/commands/android/create.js +10 -1
  21. package/dist/commands/android/create.js.map +1 -1
  22. package/dist/commands/android/delete.js +1 -1
  23. package/dist/commands/android/delete.js.map +1 -1
  24. package/dist/commands/android/install-app.d.ts.map +1 -1
  25. package/dist/commands/android/install-app.js +4 -2
  26. package/dist/commands/android/install-app.js.map +1 -1
  27. package/dist/commands/android/launch-app.d.ts.map +1 -1
  28. package/dist/commands/android/launch-app.js +20 -5
  29. package/dist/commands/android/launch-app.js.map +1 -1
  30. package/dist/commands/android/logcat.js +1 -1
  31. package/dist/commands/android/logcat.js.map +1 -1
  32. package/dist/commands/android/open-url.d.ts.map +1 -1
  33. package/dist/commands/android/open-url.js +1 -1
  34. package/dist/commands/android/open-url.js.map +1 -1
  35. package/dist/commands/android/press-key.d.ts.map +1 -1
  36. package/dist/commands/android/press-key.js +1 -1
  37. package/dist/commands/android/press-key.js.map +1 -1
  38. package/dist/commands/android/record.d.ts.map +1 -1
  39. package/dist/commands/android/record.js +26 -5
  40. package/dist/commands/android/record.js.map +1 -1
  41. package/dist/commands/android/scroll.d.ts.map +1 -1
  42. package/dist/commands/android/scroll.js +7 -1
  43. package/dist/commands/android/scroll.js.map +1 -1
  44. package/dist/commands/android/set-wifi-bandwidth.d.ts.map +1 -1
  45. package/dist/commands/android/set-wifi-bandwidth.js +1 -1
  46. package/dist/commands/android/set-wifi-bandwidth.js.map +1 -1
  47. package/dist/commands/android/sync.d.ts.map +1 -1
  48. package/dist/commands/android/sync.js +10 -2
  49. package/dist/commands/android/sync.js.map +1 -1
  50. package/dist/commands/android/tap.d.ts.map +1 -1
  51. package/dist/commands/android/tap.js +1 -1
  52. package/dist/commands/android/tap.js.map +1 -1
  53. package/dist/commands/android/terminate-app.d.ts.map +1 -1
  54. package/dist/commands/android/terminate-app.js +1 -1
  55. package/dist/commands/android/terminate-app.js.map +1 -1
  56. package/dist/commands/android/tunnel/index.js +2 -2
  57. package/dist/commands/android/tunnel/index.js.map +1 -1
  58. package/dist/commands/android/type.d.ts.map +1 -1
  59. package/dist/commands/android/type.js +1 -1
  60. package/dist/commands/android/type.js.map +1 -1
  61. package/dist/commands/asset/delete.js +1 -1
  62. package/dist/commands/asset/delete.js.map +1 -1
  63. package/dist/commands/asset/pull.js +1 -1
  64. package/dist/commands/asset/pull.js.map +1 -1
  65. package/dist/commands/asset/push.d.ts.map +1 -1
  66. package/dist/commands/asset/push.js +4 -0
  67. package/dist/commands/asset/push.js.map +1 -1
  68. package/dist/commands/gradle/build.d.ts +1 -0
  69. package/dist/commands/gradle/build.d.ts.map +1 -1
  70. package/dist/commands/gradle/build.js +21 -2
  71. package/dist/commands/gradle/build.js.map +1 -1
  72. package/dist/commands/gradle/delete.js +1 -1
  73. package/dist/commands/gradle/delete.js.map +1 -1
  74. package/dist/commands/gradle/sync.d.ts.map +1 -1
  75. package/dist/commands/gradle/sync.js +10 -2
  76. package/dist/commands/gradle/sync.js.map +1 -1
  77. package/dist/commands/ios/app-log.d.ts.map +1 -1
  78. package/dist/commands/ios/app-log.js +6 -3
  79. package/dist/commands/ios/app-log.js.map +1 -1
  80. package/dist/commands/ios/camera.d.ts.map +1 -1
  81. package/dist/commands/ios/camera.js +8 -2
  82. package/dist/commands/ios/camera.js.map +1 -1
  83. package/dist/commands/ios/create.d.ts.map +1 -1
  84. package/dist/commands/ios/create.js +5 -1
  85. package/dist/commands/ios/create.js.map +1 -1
  86. package/dist/commands/ios/delete.js +1 -1
  87. package/dist/commands/ios/delete.js.map +1 -1
  88. package/dist/commands/ios/keychain/generate-key.d.ts.map +1 -1
  89. package/dist/commands/ios/keychain/generate-key.js +2 -1
  90. package/dist/commands/ios/keychain/generate-key.js.map +1 -1
  91. package/dist/commands/ios/launch-app.d.ts.map +1 -1
  92. package/dist/commands/ios/launch-app.js +19 -4
  93. package/dist/commands/ios/launch-app.js.map +1 -1
  94. package/dist/commands/ios/maestro.d.ts.map +1 -1
  95. package/dist/commands/ios/maestro.js +4 -1
  96. package/dist/commands/ios/maestro.js.map +1 -1
  97. package/dist/commands/ios/open-url.d.ts.map +1 -1
  98. package/dist/commands/ios/open-url.js +1 -1
  99. package/dist/commands/ios/open-url.js.map +1 -1
  100. package/dist/commands/ios/press-key.d.ts.map +1 -1
  101. package/dist/commands/ios/press-key.js +1 -1
  102. package/dist/commands/ios/press-key.js.map +1 -1
  103. package/dist/commands/ios/record.d.ts.map +1 -1
  104. package/dist/commands/ios/record.js +28 -5
  105. package/dist/commands/ios/record.js.map +1 -1
  106. package/dist/commands/ios/scroll.d.ts.map +1 -1
  107. package/dist/commands/ios/scroll.js +9 -1
  108. package/dist/commands/ios/scroll.js.map +1 -1
  109. package/dist/commands/ios/swipe.d.ts.map +1 -1
  110. package/dist/commands/ios/swipe.js +1 -1
  111. package/dist/commands/ios/swipe.js.map +1 -1
  112. package/dist/commands/ios/sync.d.ts.map +1 -1
  113. package/dist/commands/ios/sync.js +12 -3
  114. package/dist/commands/ios/sync.js.map +1 -1
  115. package/dist/commands/ios/syslog.js +1 -1
  116. package/dist/commands/ios/syslog.js.map +1 -1
  117. package/dist/commands/ios/tap.d.ts.map +1 -1
  118. package/dist/commands/ios/tap.js +1 -1
  119. package/dist/commands/ios/tap.js.map +1 -1
  120. package/dist/commands/ios/terminate-app.d.ts.map +1 -1
  121. package/dist/commands/ios/terminate-app.js +1 -1
  122. package/dist/commands/ios/terminate-app.js.map +1 -1
  123. package/dist/commands/ios/toggle-keyboard.js +1 -1
  124. package/dist/commands/ios/toggle-keyboard.js.map +1 -1
  125. package/dist/commands/ios/tunnel/index.js +2 -2
  126. package/dist/commands/ios/tunnel/index.js.map +1 -1
  127. package/dist/commands/ios/type.d.ts.map +1 -1
  128. package/dist/commands/ios/type.js +7 -1
  129. package/dist/commands/ios/type.js.map +1 -1
  130. package/dist/commands/logout.js +1 -1
  131. package/dist/commands/logout.js.map +1 -1
  132. package/dist/commands/session/start.d.ts.map +1 -1
  133. package/dist/commands/session/start.js +2 -2
  134. package/dist/commands/session/start.js.map +1 -1
  135. package/dist/commands/session/status.js +2 -2
  136. package/dist/commands/session/status.js.map +1 -1
  137. package/dist/commands/session/stop.d.ts.map +1 -1
  138. package/dist/commands/session/stop.js +12 -5
  139. package/dist/commands/session/stop.js.map +1 -1
  140. package/dist/commands/tunnel/delete.d.ts.map +1 -1
  141. package/dist/commands/tunnel/delete.js +1 -1
  142. package/dist/commands/tunnel/delete.js.map +1 -1
  143. package/dist/commands/tunnel/run.d.ts.map +1 -1
  144. package/dist/commands/tunnel/run.js +6 -3
  145. package/dist/commands/tunnel/run.js.map +1 -1
  146. package/dist/commands/xcode/build.d.ts +1 -0
  147. package/dist/commands/xcode/build.d.ts.map +1 -1
  148. package/dist/commands/xcode/build.js +28 -3
  149. package/dist/commands/xcode/build.js.map +1 -1
  150. package/dist/commands/xcode/build.test.d.ts +2 -0
  151. package/dist/commands/xcode/build.test.d.ts.map +1 -0
  152. package/dist/commands/xcode/build.test.js +87 -0
  153. package/dist/commands/xcode/build.test.js.map +1 -0
  154. package/dist/commands/xcode/create.d.ts.map +1 -1
  155. package/dist/commands/xcode/create.js +12 -3
  156. package/dist/commands/xcode/create.js.map +1 -1
  157. package/dist/commands/xcode/delete.js +1 -1
  158. package/dist/commands/xcode/delete.js.map +1 -1
  159. package/dist/commands/xcode/rbe/index.d.ts.map +1 -1
  160. package/dist/commands/xcode/rbe/index.js +6 -0
  161. package/dist/commands/xcode/rbe/index.js.map +1 -1
  162. package/dist/commands/xcode/sync.d.ts.map +1 -1
  163. package/dist/commands/xcode/sync.js +10 -2
  164. package/dist/commands/xcode/sync.js.map +1 -1
  165. package/dist/json-contract.test.d.ts +2 -0
  166. package/dist/json-contract.test.d.ts.map +1 -0
  167. package/dist/json-contract.test.js +703 -0
  168. package/dist/json-contract.test.js.map +1 -0
  169. package/dist/json-errors.test.d.ts +2 -0
  170. package/dist/json-errors.test.d.ts.map +1 -0
  171. package/dist/json-errors.test.js +95 -0
  172. package/dist/json-errors.test.js.map +1 -0
  173. package/dist/lib/build-command-helpers.d.ts +4 -1
  174. package/dist/lib/build-command-helpers.d.ts.map +1 -1
  175. package/dist/lib/build-command-helpers.js +22 -3
  176. package/dist/lib/build-command-helpers.js.map +1 -1
  177. package/dist/lib/build-run-command.d.ts.map +1 -1
  178. package/dist/lib/build-run-command.js +1 -1
  179. package/dist/lib/build-run-command.js.map +1 -1
  180. package/dist/lib/build-tools-command.d.ts.map +1 -1
  181. package/dist/lib/build-tools-command.js +1 -1
  182. package/dist/lib/build-tools-command.js.map +1 -1
  183. package/dist/lib/build-use-command.d.ts.map +1 -1
  184. package/dist/lib/build-use-command.js +9 -2
  185. package/dist/lib/build-use-command.js.map +1 -1
  186. package/dist/lib/build-use-command.test.js +11 -0
  187. package/dist/lib/build-use-command.test.js.map +1 -1
  188. package/dist/lib/daemon-client.d.ts +1 -1
  189. package/dist/lib/daemon-client.d.ts.map +1 -1
  190. package/dist/lib/daemon-client.js +2 -2
  191. package/dist/lib/daemon-client.js.map +1 -1
  192. package/dist/lib/instance-client-factory.d.ts.map +1 -1
  193. package/dist/lib/instance-client-factory.js +13 -3
  194. package/dist/lib/instance-client-factory.js.map +1 -1
  195. package/dist/lib/instance-client-factory.test.js +35 -0
  196. package/dist/lib/instance-client-factory.test.js.map +1 -1
  197. package/package.json +4 -3
@@ -0,0 +1,703 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const events_1 = require("events");
37
+ const child_process_1 = require("child_process");
38
+ const instance_client_factory_1 = require("./lib/instance-client-factory");
39
+ const daemon = __importStar(require("./lib/daemon"));
40
+ const build_command_helpers_1 = require("./lib/build-command-helpers");
41
+ jest.mock('@limrun/api', () => ({ ...jest.requireActual('@limrun/api'), createInstanceClient: jest.fn() }));
42
+ jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: jest.fn() }));
43
+ jest.mock('./lib/config', () => ({
44
+ ...jest.requireActual('./lib/config'),
45
+ registerCreatedInstance: jest.fn(),
46
+ clearLastInstanceId: jest.fn(),
47
+ writeConfig: jest.fn(),
48
+ clearConfig: jest.fn(),
49
+ }));
50
+ jest.mock('./lib/instance-client-factory', () => ({
51
+ ...jest.requireActual('./lib/instance-client-factory'),
52
+ ensureDaemonSession: jest.fn(),
53
+ getAndroidInstanceClient: jest.fn(),
54
+ getIosInstanceClient: jest.fn(),
55
+ sendSessionCommand: jest.fn(),
56
+ }));
57
+ jest.mock('./lib/daemon', () => ({
58
+ ...jest.requireActual('./lib/daemon'),
59
+ stopDaemon: jest.fn(),
60
+ clearSession: jest.fn(),
61
+ isDaemonRunning: jest.fn(),
62
+ listActiveSessions: jest.fn(),
63
+ getDaemonPid: jest.fn().mockReturnValue(123),
64
+ spawnSessionDaemon: jest.fn(),
65
+ }));
66
+ jest.mock('./lib/backend', () => ({
67
+ ...jest.requireActual('./lib/backend'),
68
+ tunnelOrganization: jest.fn().mockResolvedValue('org_test'),
69
+ findTunnel: jest.fn().mockResolvedValue({ id: 'tunnel_test' }),
70
+ deleteTunnel: jest.fn(),
71
+ }));
72
+ const instance = {
73
+ metadata: { id: 'android_useb_01m52x3ensfq9bebh7aegpd77q' },
74
+ spec: { region: 'us-east2' },
75
+ status: { state: 'ready', apiUrl: 'https://instance.example.test', token: 'test-token' },
76
+ };
77
+ const target = { id: instance.metadata.id, type: 'android', apiUrl: instance.status.apiUrl, token: 'test' };
78
+ let stdout;
79
+ let stderr;
80
+ let client;
81
+ let cleanup;
82
+ beforeEach(() => {
83
+ jest.clearAllMocks();
84
+ stdout = '';
85
+ stderr = '';
86
+ jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
87
+ stdout += String(chunk);
88
+ return true;
89
+ });
90
+ jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
91
+ stderr += String(chunk);
92
+ return true;
93
+ });
94
+ jest.spyOn(console, 'log').mockImplementation((...args) => {
95
+ stdout += require('util').format(...args) + '\n';
96
+ });
97
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
98
+ stderr += require('util').format(...args) + '\n';
99
+ });
100
+ cleanup = jest.fn();
101
+ client = Object.fromEntries([
102
+ 'setText',
103
+ 'scrollScreen',
104
+ 'scrollElement',
105
+ 'tap',
106
+ 'typeText',
107
+ 'pressKey',
108
+ 'scroll',
109
+ 'openURL',
110
+ 'openUrl',
111
+ 'terminateApp',
112
+ 'toggleKeyboard',
113
+ 'performActions',
114
+ 'sendAsset',
115
+ 'launchApp',
116
+ 'setWifiBandwidth',
117
+ 'startRecording',
118
+ 'stopRecording',
119
+ ].map((name) => [name, jest.fn().mockResolvedValue(undefined)]));
120
+ jest.mocked(instance_client_factory_1.getAndroidInstanceClient).mockResolvedValue({ client, disconnect: cleanup });
121
+ jest.mocked(instance_client_factory_1.getIosInstanceClient).mockResolvedValue({ client, disconnect: cleanup });
122
+ jest.mocked(daemon.listActiveSessions).mockReturnValue([]);
123
+ jest.mocked(daemon.isDaemonRunning).mockReturnValue(false);
124
+ jest.mocked(child_process_1.spawn).mockReturnValue({ pid: 11656, unref: jest.fn() });
125
+ });
126
+ afterEach(() => {
127
+ jest.restoreAllMocks();
128
+ jest.useRealTimers();
129
+ });
130
+ function command(name, args = {}, flags = {}) {
131
+ const Class = require(`./commands/${name}`).default;
132
+ const result = new Class([], {});
133
+ Object.assign(result, {
134
+ _parsedFlags: { json: true, ...flags },
135
+ parse: async () => ({ args, flags: { json: true, ...flags } }),
136
+ setParsedFlags(value) {
137
+ this._parsedFlags = value;
138
+ },
139
+ withAuth: (fn) => fn(),
140
+ resolveAndroidInstance: () => target,
141
+ resolveIosInstance: () => ({ ...target, type: 'ios' }),
142
+ resolveDeviceInstance: () => target,
143
+ resolveGradleTarget: () => target,
144
+ resolveXcodeTarget: async () => target,
145
+ resolveAndroidInstanceOrCreate: async () => target,
146
+ resolveIosInstanceOrCreate: async () => target,
147
+ resolveXcodeTargetOrCreate: async () => target,
148
+ resolveGradleTargetOrCreate: async () => target,
149
+ resolveXcodeClientForWork: async () => client,
150
+ resolveGradleClient: async () => client,
151
+ consoleStreamUrl: () => 'https://console.example.test',
152
+ signedStreamUrl: () => undefined,
153
+ });
154
+ const resource = {
155
+ create: jest.fn().mockResolvedValue(instance),
156
+ delete: jest.fn(),
157
+ get: jest.fn().mockResolvedValue(instance),
158
+ };
159
+ Object.defineProperty(result, 'client', {
160
+ value: {
161
+ androidInstances: resource,
162
+ iosInstances: resource,
163
+ xcodeInstances: resource,
164
+ gradleInstances: resource,
165
+ assets: resource,
166
+ },
167
+ });
168
+ return result;
169
+ }
170
+ const actions = [
171
+ ['tap', { x: 0, y: 200 }, {}, { x: 0, y: 200 }],
172
+ ['type', { text: 'hello\nworld' }, { enter: false, 'require-focus': true }, { textLength: 11 }],
173
+ ['press-key', { key: 'home' }, {}, { key: 'home', modifiers: [] }],
174
+ ['scroll', { direction: 'down' }, { amount: 300 }, { direction: 'down', amount: 300 }],
175
+ ['open-url', { url: 'https://example.test' }, {}, { url: 'https://example.test' }],
176
+ ['terminate-app', { bundleId: 'com.example.app', packageName: 'com.example.app' }, {}, {}],
177
+ ];
178
+ describe.each([false, true])('device actions with daemon=%s', (session) => {
179
+ beforeEach(() => jest.mocked(instance_client_factory_1.ensureDaemonSession).mockResolvedValue(session));
180
+ test.each(['android', 'ios'].flatMap((platform) => actions.map(([name, args, flags, expected]) => [`${platform}/${name}`, args, flags, expected])))('%s emits the resolved target and action parameters', async (name, args, flags, expected) => {
181
+ await command(name, args, flags).run();
182
+ expect(JSON.parse(stdout)).toEqual({
183
+ ok: true,
184
+ instanceId: target.id,
185
+ ...expected,
186
+ ...(name === 'ios/type' ? { enter: false, requireFocus: true } : {}),
187
+ ...(name === 'ios/terminate-app' ? { bundleId: args.bundleId } : {}),
188
+ ...(name === 'android/terminate-app' ? { packageName: args.packageName } : {}),
189
+ });
190
+ if (session && name !== 'android/terminate-app')
191
+ expect(instance_client_factory_1.sendSessionCommand).toHaveBeenCalled();
192
+ else
193
+ expect(cleanup).toHaveBeenCalledTimes(1);
194
+ });
195
+ test('iOS gesture results preserve parsed values and zero momentum', async () => {
196
+ await command('ios/scroll', { direction: 'up' }, { amount: 500, momentum: '0', coordinate: '0,240' }).run();
197
+ expect(JSON.parse(stdout)).toEqual({
198
+ ok: true,
199
+ instanceId: target.id,
200
+ direction: 'up',
201
+ amount: 500,
202
+ momentum: 0,
203
+ coordinate: [0, 240],
204
+ });
205
+ stdout = '';
206
+ await command('ios/swipe', {}, { from: '10,20', to: '30,40', duration: 800 }).run();
207
+ expect(JSON.parse(stdout)).toEqual({
208
+ ok: true,
209
+ instanceId: target.id,
210
+ from: [10, 20],
211
+ to: [30, 40],
212
+ durationMs: 800,
213
+ });
214
+ });
215
+ test('keyboard toggle identifies the instance without inventing visibility', async () => {
216
+ await command('ios/toggle-keyboard').run();
217
+ expect(JSON.parse(stdout)).toEqual({ ok: true, instanceId: target.id });
218
+ });
219
+ test.each(['android', 'ios'])('%s key result includes modifiers', async (platform) => {
220
+ await command(`${platform}/press-key`, { key: 'a' }, { modifier: ['shift', 'control'] }).run();
221
+ expect(JSON.parse(stdout)).toEqual({
222
+ ok: true,
223
+ instanceId: target.id,
224
+ key: 'a',
225
+ modifiers: ['shift', 'control'],
226
+ });
227
+ });
228
+ test('Android actions report the actual selector or coordinate target', async () => {
229
+ await command('android/scroll', { direction: 'down' }, { amount: 500, 'resource-id': 'com.example:id/list', enabled: false }).run();
230
+ expect(JSON.parse(stdout)).toEqual({
231
+ ok: true,
232
+ instanceId: target.id,
233
+ direction: 'down',
234
+ amount: 500,
235
+ target: { selector: { resourceId: 'com.example:id/list', enabled: false } },
236
+ });
237
+ stdout = '';
238
+ await command('android/type', { text: 'secret' }, { x: 0, y: 20 }).run();
239
+ expect(JSON.parse(stdout)).toEqual({
240
+ ok: true,
241
+ instanceId: target.id,
242
+ textLength: 6,
243
+ target: { x: 0, y: 20 },
244
+ });
245
+ });
246
+ test.each([true, false])('Android install result identifies its source, local=%s', async (local) => {
247
+ const source = local ? '/tmp/app.apk' : 'https://example.test/app.apk';
248
+ const c = command('android/install-app', { path_or_url: source });
249
+ jest.spyOn(require('fs'), 'existsSync').mockReturnValue(true);
250
+ c.client.assets.getOrUpload = jest
251
+ .fn()
252
+ .mockResolvedValue({ id: 'asset_test', signedDownloadUrl: 'https://download.test/signed' });
253
+ await c.run();
254
+ expect(JSON.parse(stdout)).toEqual({
255
+ ok: true,
256
+ instanceId: target.id,
257
+ source,
258
+ ...(local ? { assetId: 'asset_test' } : {}),
259
+ });
260
+ });
261
+ test.each(['android', 'ios'])('%s record start and stop have stable responses', async (platform) => {
262
+ await command(`${platform}/record`, { action: 'start' }, { quality: 5 }).run();
263
+ expect(JSON.parse(stdout)).toEqual({
264
+ ok: true,
265
+ instanceId: target.id,
266
+ recording: true,
267
+ quality: 5,
268
+ persist: false,
269
+ });
270
+ stdout = '';
271
+ await command(`${platform}/record`, { action: 'stop' }, { output: '/tmp/contract-recording.mp4', 'presigned-url': 'https://upload.example.test' }).run();
272
+ expect(JSON.parse(stdout)).toEqual({
273
+ ok: true,
274
+ instanceId: target.id,
275
+ recording: false,
276
+ localPath: '/tmp/contract-recording.mp4',
277
+ uploaded: true,
278
+ });
279
+ });
280
+ test('bandwidth preserves zero and omits the unchanged direction', async () => {
281
+ await command('android/set-wifi-bandwidth', {}, { 'down-kbps': 0 }).run();
282
+ expect(JSON.parse(stdout)).toEqual({ ok: true, instanceId: target.id, downKbps: 0 });
283
+ });
284
+ });
285
+ test.each(['android', 'ios', 'xcode', 'gradle'])('%s delete emits the resource ID', async (platform) => {
286
+ await command(`${platform}/delete`, { id: target.id }).run();
287
+ expect(JSON.parse(stdout)).toEqual({ ok: true, id: target.id });
288
+ });
289
+ test.each([true, false])('Android create emits the API resource with connect=%s', async (connect) => {
290
+ await command('android/create', {}, { connect, daemon: true, open: true, 'hard-timeout': '10m' }).run();
291
+ expect(JSON.parse(stdout)).toEqual(instance);
292
+ expect(stderr).toBe('');
293
+ expect(child_process_1.spawn).toHaveBeenCalledTimes(connect ? 1 : 0);
294
+ });
295
+ test('Android create retains human tunnel instructions without --json', async () => {
296
+ await command('android/create', {}, { json: false, connect: true, daemon: true }).run();
297
+ expect(stdout).toContain('ADB tunnel starting in background (PID 11656).');
298
+ expect(stdout).toContain(`lim android delete ${instance.metadata.id}`);
299
+ });
300
+ test('an accidental human log cannot corrupt JSON stdout', async () => {
301
+ const c = command('android/create');
302
+ c.log('diagnostic');
303
+ c.output('another diagnostic');
304
+ c.outputJson(instance);
305
+ expect(JSON.parse(stdout)).toEqual(instance);
306
+ expect(stderr).toContain('diagnostic');
307
+ });
308
+ test.each(['android', 'ios', 'xcode', 'gradle'])('%s sync emits one final result or NDJSON watch events', async (platform) => {
309
+ const data = {
310
+ bytesSent: 123,
311
+ durationMs: 12,
312
+ ...(platform === 'ios' ? { installedBundleId: 'com.example.app' } : {}),
313
+ };
314
+ client.syncApp = client.sync = jest.fn().mockImplementation(async (_path, options) => {
315
+ options.onSyncComplete(data);
316
+ return data;
317
+ });
318
+ await command(`${platform}/sync`, { path: '/tmp/project' }).run();
319
+ expect(JSON.parse(stdout)).toEqual(data);
320
+ stdout = '';
321
+ // The mocked watcher completes immediately after its last update.
322
+ client.syncApp = client.sync = jest.fn().mockImplementation(async (_path, options) => {
323
+ options.onSyncComplete(data);
324
+ options.onSyncComplete({ ...data, bytesSent: 3 });
325
+ return data;
326
+ });
327
+ await command(`${platform}/sync`, { path: '/tmp/project' }, { watch: true }).run();
328
+ expect(stdout
329
+ .trim()
330
+ .split('\n')
331
+ .map((line) => JSON.parse(line))).toEqual([data, { ...data, bytesSent: 3 }]);
332
+ });
333
+ test('session stop --all emits a single result, including an empty list', async () => {
334
+ await command('session/stop', {}, { all: true }).run();
335
+ expect(JSON.parse(stdout)).toEqual({ stopped: [] });
336
+ stdout = '';
337
+ jest.mocked(daemon.listActiveSessions).mockReturnValue([
338
+ { instanceId: 'ios_one', pid: 11 },
339
+ { instanceId: 'android_two', pid: 12 },
340
+ ]);
341
+ await command('session/stop', {}, { all: true }).run();
342
+ expect(JSON.parse(stdout)).toEqual({ stopped: ['ios_one', 'android_two'] });
343
+ expect(daemon.stopDaemon).toHaveBeenCalledTimes(2);
344
+ });
345
+ test.each([true, false])('session start reports alreadyRunning=%s', async (running) => {
346
+ jest.mocked(daemon.isDaemonRunning).mockReturnValue(running);
347
+ await command('session/start').run();
348
+ expect(JSON.parse(stdout)).toEqual({ instanceId: target.id, running: true, alreadyRunning: running });
349
+ });
350
+ test('session stop ambiguity fails without a success result', async () => {
351
+ jest.mocked(daemon.listActiveSessions).mockReturnValue([
352
+ { instanceId: 'ios_one', pid: 11 },
353
+ { instanceId: 'android_two', pid: 12 },
354
+ ]);
355
+ await expect(command('session/stop').run()).rejects.toThrow('Specify --id or --all');
356
+ expect(stdout).toBe('');
357
+ });
358
+ test('JSON log records escape embedded newlines', () => {
359
+ const c = command('ios/syslog');
360
+ c.outputEvent({ line: 'one\ntwo' });
361
+ c.outputEvent({ line: 'three' });
362
+ expect(stdout
363
+ .trim()
364
+ .split('\n')
365
+ .map((line) => JSON.parse(line))).toEqual([{ line: 'one\ntwo' }, { line: 'three' }]);
366
+ });
367
+ test.each([0, 7])('remote commands preserve stdout, stderr and exit code %s', async (exitCode) => {
368
+ const out = new events_1.EventEmitter();
369
+ const err = new events_1.EventEmitter();
370
+ const proc = Object.assign(new Promise((resolve) => {
371
+ setImmediate(() => {
372
+ out.emit('data', 'hello');
373
+ err.emit('data', 'warning');
374
+ resolve({ execId: 'exec_test', status: exitCode ? 'FAILED' : 'SUCCEEDED', exitCode });
375
+ });
376
+ }), { stdout: out, stderr: err });
377
+ const reportError = jest.fn((message) => {
378
+ throw new Error(message);
379
+ });
380
+ const result = jest.fn();
381
+ const run = (0, build_command_helpers_1.streamBuildCommand)(proc, reportError, 'Command failed', result);
382
+ if (exitCode)
383
+ await expect(run).rejects.toMatchObject({ code: 'EEXIT', oclif: { exit: 7 } });
384
+ else
385
+ await run;
386
+ expect(result).toHaveBeenCalledWith({
387
+ execId: 'exec_test',
388
+ status: exitCode ? 'FAILED' : 'SUCCEEDED',
389
+ exitCode,
390
+ stdout: 'hello\n',
391
+ stderr: 'warning\n',
392
+ });
393
+ expect(stdout).toBe('');
394
+ expect(reportError).not.toHaveBeenCalled();
395
+ });
396
+ describe.each([false, true])('foreground build NDJSON with quiet=%s', (quiet) => {
397
+ test.each(['xcode', 'gradle'])('%s streams output before its terminal execution result', async (platform) => {
398
+ for (const exitCode of [0, 65, 124]) {
399
+ stdout = '';
400
+ stderr = '';
401
+ const expected = {
402
+ execId: 'exec_test',
403
+ status: exitCode === 124 ? 'RUNNING'
404
+ : exitCode ? 'FAILED'
405
+ : 'SUCCEEDED',
406
+ exitCode,
407
+ ...(exitCode === 124 ?
408
+ { timedOut: true, incomplete: { reason: 'stream-lost', message: 'Connection lost' } }
409
+ : exitCode ? {}
410
+ : { signedDownloadUrl: 'https://download.example.test/app' }),
411
+ };
412
+ const out = new events_1.EventEmitter();
413
+ const err = new events_1.EventEmitter();
414
+ client.sync = jest.fn().mockResolvedValue({ bytesSent: 100 });
415
+ client.gradlebuild = client.xcodebuild = jest.fn().mockImplementation(() => Object.assign(new Promise((resolve) => {
416
+ setImmediate(() => {
417
+ out.emit('data', 'compiling');
418
+ err.emit('data', 'warning\nwith details');
419
+ out.emit('data', '');
420
+ // Logs must reach the consumer before the execution completes.
421
+ expect(stdout
422
+ .trim()
423
+ .split('\n')
424
+ .map((line) => JSON.parse(line))).toEqual([
425
+ { type: 'stdout', line: 'compiling' },
426
+ { type: 'stderr', line: 'warning\nwith details' },
427
+ { type: 'stdout', line: '' },
428
+ ]);
429
+ resolve(expected);
430
+ });
431
+ }), { stdout: out, stderr: err }));
432
+ const c = command(`${platform}/build`, {}, { quiet });
433
+ c.applyBuildCacheToTarget = async () => { };
434
+ if (exitCode)
435
+ await expect(c.run()).rejects.toMatchObject({ code: 'EEXIT', oclif: { exit: exitCode } });
436
+ else
437
+ await c.run();
438
+ expect(stdout
439
+ .trim()
440
+ .split('\n')
441
+ .map((line) => JSON.parse(line))).toEqual([
442
+ { type: 'stdout', line: 'compiling' },
443
+ { type: 'stderr', line: 'warning\nwith details' },
444
+ { type: 'stdout', line: '' },
445
+ { type: 'result', instanceId: target.id, ...expected },
446
+ ]);
447
+ expect(stderr).toBe('');
448
+ }
449
+ });
450
+ });
451
+ test.each(['xcode', 'gradle'])('%s build keeps human output on its original streams', async (platform) => {
452
+ const out = new events_1.EventEmitter();
453
+ const err = new events_1.EventEmitter();
454
+ client.sync = jest.fn().mockResolvedValue({ bytesSent: 100 });
455
+ client.gradlebuild = client.xcodebuild = jest.fn(() => Object.assign(new Promise((resolve) => {
456
+ setImmediate(() => {
457
+ out.emit('data', 'compiling');
458
+ err.emit('data', 'warning');
459
+ resolve({ exitCode: 0 });
460
+ });
461
+ }), { stdout: out, stderr: err }));
462
+ const c = command(`${platform}/build`, {}, { json: false });
463
+ c.applyBuildCacheToTarget = async () => { };
464
+ c.buildSnapshotTip = async () => '';
465
+ await c.run();
466
+ expect(stdout).toContain('compiling\n');
467
+ expect(stdout).toContain('Build succeeded');
468
+ expect(stderr).toBe('warning\n');
469
+ });
470
+ test.each(['android', 'ios'])('%s detached launch returns an acknowledgement', async (platform) => {
471
+ await command(`${platform}/launch-app`, { packageName: 'com.example.app', bundleId: 'com.example.app' }, { detach: true }).run();
472
+ expect(JSON.parse(stdout)).toEqual({
473
+ ok: true,
474
+ instanceId: target.id,
475
+ [platform === 'ios' ? 'bundleId' : 'packageName']: 'com.example.app',
476
+ mode: 'ForegroundIfRunning',
477
+ });
478
+ });
479
+ test('asset push preserves the API response', async () => {
480
+ const fs = require('fs');
481
+ jest.spyOn(fs, 'existsSync').mockReturnValue(true);
482
+ const c = command('asset/push', { file: '/tmp/app.apk' });
483
+ const asset = { id: 'asset_test', name: 'app.apk', expiresAt: '2026-10-17T10:00:00Z', size: 234 };
484
+ c.client.assets.getOrUpload = jest.fn().mockResolvedValue(asset);
485
+ await c.run();
486
+ expect(JSON.parse(stdout)).toEqual(asset);
487
+ });
488
+ test('key generation returns a named base64 key', async () => {
489
+ await command('ios/keychain/generate-key').run();
490
+ const value = JSON.parse(stdout);
491
+ expect(Object.keys(value)).toEqual(['key']);
492
+ expect(Buffer.from(value.key, 'base64')).toHaveLength(32);
493
+ });
494
+ test('tunnel and asset deletion return their IDs', async () => {
495
+ await command('tunnel/delete', { name: 'staging' }).run();
496
+ expect(JSON.parse(stdout)).toEqual({ ok: true, id: 'tunnel_test' });
497
+ stdout = '';
498
+ await command('asset/delete', { id: 'asset_test' }).run();
499
+ expect(JSON.parse(stdout)).toEqual({ ok: true, id: 'asset_test' });
500
+ });
501
+ test('action failure emits no success acknowledgement', async () => {
502
+ jest.mocked(instance_client_factory_1.ensureDaemonSession).mockResolvedValue(false);
503
+ client.tap.mockRejectedValue(new Error('device disconnected'));
504
+ await expect(command('ios/tap', { x: 1, y: 2 }).run()).rejects.toThrow('device disconnected');
505
+ expect(stdout).toBe('');
506
+ });
507
+ test.each([false, true])('Android foreground create emits JSON before waiting, rm=%s', async (rm) => {
508
+ const api = require('@limrun/api');
509
+ const signals = [];
510
+ const tunnel = { address: { address: '127.0.0.1', port: 5555 }, close: jest.fn() };
511
+ const direct = { startAdbTunnel: jest.fn().mockResolvedValue(tunnel), disconnect: jest.fn() };
512
+ api.createInstanceClient.mockResolvedValue(direct);
513
+ const originalOn = process.on.bind(process);
514
+ jest.spyOn(process, 'on').mockImplementation(((event, handler) => {
515
+ if (event === 'SIGINT') {
516
+ signals.push(handler);
517
+ return process;
518
+ }
519
+ if (event === 'SIGTERM')
520
+ return process;
521
+ return originalOn(event, handler);
522
+ }));
523
+ const c = command('android/create', {}, { connect: true, daemon: rm, rm });
524
+ const running = c.run();
525
+ for (let i = 0; i < 20 && signals.length < (rm ? 2 : 1); i++)
526
+ await new Promise(setImmediate);
527
+ expect(JSON.parse(stdout)).toEqual(instance);
528
+ expect(api.createInstanceClient).toHaveBeenCalledWith(expect.objectContaining({ logLevel: 'warn' }));
529
+ signals[signals.length - 1]();
530
+ await running;
531
+ expect(tunnel.close).toHaveBeenCalledTimes(1);
532
+ expect(direct.disconnect).toHaveBeenCalledTimes(1);
533
+ });
534
+ test.each(['xcode', 'gradle'])('%s detached builds retain their existing contract', async (platform) => {
535
+ client.sync = jest.fn().mockResolvedValue({ bytesSent: 100 });
536
+ client.gradlebuild = client.xcodebuild = jest.fn().mockReturnValue({ detach: async () => 'exec_test' });
537
+ const c = command(`${platform}/build`, {}, { detach: true });
538
+ c.applyBuildCacheToTarget = async () => { };
539
+ c.consoleBuildUrl = () => 'https://console.example.test/build';
540
+ await c.run();
541
+ expect(JSON.parse(stdout)).toEqual({
542
+ instanceId: target.id,
543
+ execId: 'exec_test',
544
+ consoleUrl: 'https://console.example.test/build',
545
+ logsCommand: `lim ${platform} logs exec_test --id ${target.id}`,
546
+ });
547
+ });
548
+ test.each(['android/logcat', 'ios/syslog', 'ios/app-log'])('%s frames actual log callbacks as NDJSON', async (name) => {
549
+ const stream = Object.assign(new events_1.EventEmitter(), { stop: jest.fn() });
550
+ let finishLogcat = () => { };
551
+ Object.assign(stream, {
552
+ wait: () => new Promise((resolve) => {
553
+ finishLogcat = resolve;
554
+ }),
555
+ });
556
+ stream.stop.mockImplementation(() => finishLogcat(0));
557
+ client.logcat = client.streamSyslog = client.streamAppLog = jest.fn().mockReturnValue(stream);
558
+ const signals = [];
559
+ const originalOn = process.on.bind(process);
560
+ jest.spyOn(process, 'on').mockImplementation(((event, listener) => {
561
+ if (event === 'SIGINT') {
562
+ signals.push(listener);
563
+ return process;
564
+ }
565
+ if (event === 'SIGTERM')
566
+ return process;
567
+ return originalOn(event, listener);
568
+ }));
569
+ const run = command(name, { bundleId: 'com.example.app' }, { follow: true }).run();
570
+ for (let i = 0; i < 20 && !signals.length; i++)
571
+ await new Promise(setImmediate);
572
+ stream.emit('line', 'first\nsecond');
573
+ stream.emit('line', 'third');
574
+ signals[0]();
575
+ await run;
576
+ expect(stdout
577
+ .trim()
578
+ .split('\n')
579
+ .map((line) => JSON.parse(line))).toEqual([{ line: 'first\nsecond' }, { line: 'third' }]);
580
+ });
581
+ test('iOS display overrides appear in gesture results', async () => {
582
+ client.tapDisplay = jest.fn();
583
+ await command('ios/tap', { x: 0, y: 20 }, { display: 'inner' }).run();
584
+ expect(JSON.parse(stdout)).toEqual({ ok: true, instanceId: target.id, x: 0, y: 20, display: 'inner' });
585
+ expect(client.tapDisplay).toHaveBeenCalledWith('inner', 0, 20);
586
+ });
587
+ test('iOS launch reports the effective relaunch mode without echoing environment secrets', async () => {
588
+ await command('ios/launch-app', { bundleId: 'com.example.app' }, { detach: true, env: ['API_TOKEN=secret'] }).run();
589
+ expect(JSON.parse(stdout)).toEqual({
590
+ ok: true,
591
+ instanceId: target.id,
592
+ bundleId: 'com.example.app',
593
+ mode: 'RelaunchIfRunning',
594
+ });
595
+ });
596
+ test.each(['ios', 'android'])('%s recording reports requested quality and parsed retention', async (platform) => {
597
+ await command(`${platform}/record`, { action: 'start' }, {
598
+ quality: 8,
599
+ persist: true,
600
+ 'persist-ttl': '24h',
601
+ ...(platform === 'ios' ? { display: 'outer', 'touch-indicators': false } : {}),
602
+ }).run();
603
+ expect(JSON.parse(stdout)).toEqual({
604
+ ok: true,
605
+ instanceId: target.id,
606
+ recording: true,
607
+ quality: 8,
608
+ persist: true,
609
+ persistTtlSeconds: 86400,
610
+ ...(platform === 'ios' ? { display: 'outer', touchIndicators: false } : {}),
611
+ });
612
+ });
613
+ test.each(['ios', 'android'])('%s camera results identify playback and clear actions', async (platform) => {
614
+ client.setCameraVideo = jest.fn();
615
+ client.clearCameraVideo = jest.fn();
616
+ jest.spyOn(require('fs'), 'existsSync').mockReturnValue(true);
617
+ await command(`${platform}/camera`, { action: 'play', path: '/tmp/clip.mp4' }, { loop: false }).run();
618
+ expect(JSON.parse(stdout)).toEqual({
619
+ ok: true,
620
+ instanceId: target.id,
621
+ action: 'play',
622
+ localPath: '/tmp/clip.mp4',
623
+ loop: false,
624
+ });
625
+ stdout = '';
626
+ await command(`${platform}/camera`, { action: 'clear' }).run();
627
+ expect(JSON.parse(stdout)).toEqual({ ok: true, instanceId: target.id, action: 'clear' });
628
+ });
629
+ describe.each(['xcode', 'gradle'])('%s execution failures', (platform) => {
630
+ test.each(['run', 'tools', 'tools/install', 'use'])('%s emits a result and exits without a duplicate CLI error', async (name) => {
631
+ for (const timedOut of [false, true]) {
632
+ stdout = '';
633
+ stderr = '';
634
+ const exitCode = timedOut ? 124 : 7;
635
+ const expected = {
636
+ execId: 'exec_failed',
637
+ status: timedOut ? 'RUNNING' : 'FAILED',
638
+ exitCode,
639
+ ...(timedOut ?
640
+ { timedOut: true, incomplete: { reason: 'timeout', message: 'Still running' } }
641
+ : {}),
642
+ };
643
+ client.run = jest.fn(() => Object.assign(Promise.resolve(expected), {
644
+ stdout: new events_1.EventEmitter(),
645
+ stderr: new events_1.EventEmitter(),
646
+ }));
647
+ const c = command(`${platform}/${name}`, {}, { 'no-sync': true });
648
+ c.argv = ['--', 'false'];
649
+ c.parse = async () => ({ args: {}, flags: { json: true, 'no-sync': true }, argv: ['node@24'] });
650
+ c.resolveBuildToolClient = async () => ({ target, client });
651
+ await expect(c.run()).rejects.toMatchObject({ code: 'EEXIT', oclif: { exit: exitCode } });
652
+ expect(JSON.parse(stdout)).toEqual({ ...expected, stdout: '', stderr: '' });
653
+ expect(stderr).toBe('');
654
+ }
655
+ });
656
+ });
657
+ test.each([false, true])('human execution failures retain diagnostics, timedOut=%s', async (timedOut) => {
658
+ const proc = Object.assign(Promise.resolve({ exitCode: timedOut ? 124 : 7, timedOut }), {
659
+ stdout: new events_1.EventEmitter(),
660
+ stderr: new events_1.EventEmitter(),
661
+ });
662
+ const reportError = jest.fn((message) => {
663
+ throw new Error(message);
664
+ });
665
+ await expect((0, build_command_helpers_1.streamBuildCommand)(proc, reportError)).rejects.toThrow(timedOut ? 'Timed out waiting' : 'Command failed with exit code 7');
666
+ expect(reportError).toHaveBeenCalledWith(expect.any(String), { exit: timedOut ? 124 : 7 });
667
+ });
668
+ describe.each(['android', 'ios'])('%s foreground launch lifecycle', (platform) => {
669
+ test.each([true, false])('launch precedes exit, exitsDuringLaunch=%s', async (exitsDuringLaunch) => {
670
+ jest.useFakeTimers();
671
+ jest.spyOn(process, 'on').mockReturnValue(process);
672
+ const logStream = Object.assign(new events_1.EventEmitter(), { stop: jest.fn() });
673
+ client.streamAppLog = jest.fn().mockReturnValue(logStream);
674
+ const info = { [platform === 'ios' ? 'bundleId' : 'packageName']: 'com.example.app', reason: 'crash' };
675
+ client.launchApp.mockImplementation(async (_app, options) => {
676
+ if (exitsDuringLaunch)
677
+ options.onExit(['crashed'], info);
678
+ else
679
+ setTimeout(() => options.onExit(['crashed'], info), 10);
680
+ });
681
+ const run = command(`${platform}/launch-app`, {
682
+ bundleId: 'com.example.app',
683
+ packageName: 'com.example.app',
684
+ }).run();
685
+ await jest.advanceTimersByTimeAsync(1500);
686
+ await run;
687
+ expect(stdout
688
+ .trim()
689
+ .split('\n')
690
+ .map((line) => JSON.parse(line))).toEqual([
691
+ {
692
+ type: 'launched',
693
+ instanceId: target.id,
694
+ [platform === 'ios' ? 'bundleId' : 'packageName']: 'com.example.app',
695
+ mode: 'ForegroundIfRunning',
696
+ },
697
+ { type: 'exit', ...info, ...(platform === 'android' ? { logs: ['crashed'] } : {}) },
698
+ ]);
699
+ expect(cleanup).toHaveBeenCalledTimes(1);
700
+ expect(jest.getTimerCount()).toBe(0);
701
+ });
702
+ });
703
+ //# sourceMappingURL=json-contract.test.js.map