@tvlabs/wdio-service 0.1.5 → 0.1.7

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.
@@ -0,0 +1,181 @@
1
+ import { type Channel } from 'phoenix';
2
+ import { SevereServiceError } from 'webdriverio';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as crypto from 'node:crypto';
6
+
7
+ import { BaseChannel } from './base.js';
8
+
9
+ import type {
10
+ TVLabsExtractBuildInfoResponse,
11
+ TVLabsRequestUploadUrlResponse,
12
+ TVLabsBuildMetadata,
13
+ LogLevel,
14
+ } from '../types.js';
15
+
16
+ export class BuildChannel extends BaseChannel {
17
+ private lobbyTopic: Channel;
18
+
19
+ constructor(
20
+ endpoint: string,
21
+ maxReconnectRetries: number,
22
+ key: string,
23
+ logLevel: LogLevel = 'info',
24
+ ) {
25
+ super(
26
+ endpoint,
27
+ maxReconnectRetries,
28
+ key,
29
+ logLevel,
30
+ '@tvlabs/build-channel',
31
+ );
32
+ this.lobbyTopic = this.socket.channel('upload:lobby');
33
+ }
34
+
35
+ async disconnect(): Promise<void> {
36
+ return new Promise((res, _rej) => {
37
+ this.lobbyTopic.leave();
38
+ this.socket.disconnect(() => res());
39
+ });
40
+ }
41
+
42
+ async connect(): Promise<void> {
43
+ try {
44
+ this.log.debug('Connecting to build channel...');
45
+
46
+ this.socket.connect();
47
+
48
+ await this.join(this.lobbyTopic);
49
+
50
+ this.log.debug('Connected to build channel!');
51
+ } catch (error) {
52
+ this.log.error('Error connecting to build channel:', error);
53
+ throw new SevereServiceError(
54
+ 'Could not connect to build channel, please check your connection.',
55
+ );
56
+ }
57
+ }
58
+
59
+ async uploadBuild(buildPath: string, appSlug?: string): Promise<string> {
60
+ const metadata = await this.getFileMetadata(buildPath);
61
+
62
+ this.log.info(
63
+ `Requesting upload for build ${metadata.filename} (${metadata.type}, ${metadata.size} bytes)`,
64
+ );
65
+
66
+ const { existing, build_id, url } = await this.requestUploadUrl(
67
+ metadata,
68
+ appSlug,
69
+ );
70
+
71
+ if (existing) {
72
+ this.log.info('Build is pre-existing, skipping upload');
73
+ } else {
74
+ this.log.info('Uploading build...');
75
+
76
+ await this.uploadToUrl(url, buildPath, metadata);
77
+
78
+ const { application_id } = await this.extractBuildInfo();
79
+
80
+ this.log.info(`Build "${application_id}" processed successfully`);
81
+ }
82
+
83
+ return build_id;
84
+ }
85
+
86
+ private async requestUploadUrl(
87
+ metadata: TVLabsBuildMetadata,
88
+ appSlug: string | undefined,
89
+ ) {
90
+ try {
91
+ return await this.push<TVLabsRequestUploadUrlResponse>(
92
+ this.lobbyTopic,
93
+ 'request_upload_url',
94
+ { metadata, application_slug: appSlug },
95
+ );
96
+ } catch (error) {
97
+ this.log.error('Error requesting upload URL:', error);
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ private async uploadToUrl(
103
+ url: string,
104
+ filePath: string,
105
+ metadata: TVLabsBuildMetadata,
106
+ ): Promise<void> {
107
+ try {
108
+ const response = await fetch(url, {
109
+ method: 'PUT',
110
+ headers: {
111
+ 'Content-Type': metadata.type,
112
+ 'Content-Length': String(metadata.size),
113
+ },
114
+ // @ts-expect-error - FIXME: fetch types are incorrect
115
+ body: fs.createReadStream(filePath),
116
+ duplex: 'half',
117
+ });
118
+
119
+ if (!response.ok) {
120
+ throw new SevereServiceError(
121
+ `Failed to upload build to storage, got ${response.status}`,
122
+ );
123
+ }
124
+
125
+ this.log.info('Upload complete');
126
+ } catch (error) {
127
+ this.log.error('Error uploading build:', error);
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ private async extractBuildInfo() {
133
+ this.log.info('Processing uploaded build...');
134
+
135
+ try {
136
+ return await this.push<TVLabsExtractBuildInfoResponse>(
137
+ this.lobbyTopic,
138
+ 'extract_build_info',
139
+ {},
140
+ );
141
+ } catch (error) {
142
+ this.log.error('Error processing build:', error);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ private async getFileMetadata(
148
+ buildPath: string,
149
+ ): Promise<TVLabsBuildMetadata> {
150
+ const filename = path.basename(buildPath);
151
+ const size = fs.statSync(buildPath).size;
152
+ const type = this.detectMimeType(filename);
153
+ const sha256 = await this.computeSha256(buildPath);
154
+
155
+ return { filename, type, size, sha256 };
156
+ }
157
+
158
+ private async computeSha256(buildPath: string): Promise<string> {
159
+ return new Promise((resolve, reject) => {
160
+ const hash = crypto.createHash('sha256');
161
+ const stream = fs.createReadStream(buildPath);
162
+
163
+ stream.on('data', (chunk) => hash.update(chunk));
164
+ stream.on('end', () => resolve(hash.digest('hex')));
165
+ stream.on('error', (err) => reject(err));
166
+ });
167
+ }
168
+
169
+ private detectMimeType(filename: string): string {
170
+ const fileExtension = path.extname(filename).toLowerCase();
171
+
172
+ switch (fileExtension) {
173
+ case '.apk':
174
+ return 'application/vnd.android.package-archive';
175
+ case '.zip':
176
+ return 'application/zip';
177
+ default:
178
+ return 'application/octet-stream';
179
+ }
180
+ }
181
+ }
@@ -1,23 +1,17 @@
1
- import { WebSocket } from 'ws';
2
- import { Socket, type Channel } from 'phoenix';
1
+ import { type Channel } from 'phoenix';
3
2
  import { SevereServiceError } from 'webdriverio';
4
- import { Logger } from './logger.js';
5
- import { getServiceInfo } from './utils.js';
3
+ import { BaseChannel } from './base.js';
6
4
 
7
5
  import type {
8
6
  TVLabsCapabilities,
9
- TVLabsSocketParams,
10
7
  TVLabsSessionRequestEventHandler,
11
8
  TVLabsSessionRequestResponse,
12
9
  LogLevel,
13
- } from './types.js';
14
- import type { PhoenixChannelJoinResponse } from './phoenix.js';
10
+ } from '../types.js';
15
11
 
16
- export class TVLabsChannel {
17
- private socket: Socket;
12
+ export class SessionChannel extends BaseChannel {
18
13
  private lobbyTopic: Channel;
19
14
  private requestTopic?: Channel;
20
- private log: Logger;
21
15
 
22
16
  private readonly events = {
23
17
  SESSION_READY: 'session:ready',
@@ -29,22 +23,18 @@ export class TVLabsChannel {
29
23
  } as const;
30
24
 
31
25
  constructor(
32
- private endpoint: string,
33
- private maxReconnectRetries: number,
34
- private key: string,
35
- private logLevel: LogLevel = 'info',
26
+ endpoint: string,
27
+ maxReconnectRetries: number,
28
+ key: string,
29
+ logLevel: LogLevel = 'info',
36
30
  ) {
37
- this.log = new Logger('@tvlabs/wdio-channel', this.logLevel);
38
- this.socket = new Socket(this.endpoint, {
39
- transport: WebSocket,
40
- params: this.params(),
41
- reconnectAfterMs: this.reconnectAfterMs.bind(this),
42
- });
43
-
44
- this.socket.onError((...args) =>
45
- TVLabsChannel.logSocketError(this.log, ...args),
31
+ super(
32
+ endpoint,
33
+ maxReconnectRetries,
34
+ key,
35
+ logLevel,
36
+ '@tvlabs/session-channel',
46
37
  );
47
-
48
38
  this.lobbyTopic = this.socket.channel('requests:lobby');
49
39
  }
50
40
 
@@ -58,17 +48,17 @@ export class TVLabsChannel {
58
48
 
59
49
  async connect(): Promise<void> {
60
50
  try {
61
- this.log.debug('Connecting to TV Labs...');
51
+ this.log.debug('Connecting to session channel...');
62
52
 
63
53
  this.socket.connect();
64
54
 
65
55
  await this.join(this.lobbyTopic);
66
56
 
67
- this.log.debug('Connected to TV Labs!');
57
+ this.log.debug('Connected to session channel!');
68
58
  } catch (error) {
69
- this.log.error('Error connecting to TV Labs:', error);
59
+ this.log.error('Error connecting to session channel:', error);
70
60
  throw new SevereServiceError(
71
- 'Could not connect to TV Labs, please check your connection.',
61
+ 'Could not connect to session channel, please check your connection.',
72
62
  );
73
63
  }
74
64
  }
@@ -193,79 +183,7 @@ export class TVLabsChannel {
193
183
  }
194
184
  }
195
185
 
196
- private async join(topic: Channel): Promise<void> {
197
- return new Promise((res, rej) => {
198
- topic
199
- .join()
200
- .receive('ok', (_resp: PhoenixChannelJoinResponse) => {
201
- res();
202
- })
203
- .receive('error', ({ response }: PhoenixChannelJoinResponse) => {
204
- rej('Failed to join topic: ' + response);
205
- })
206
- .receive('timeout', () => {
207
- rej('timeout');
208
- });
209
- });
210
- }
211
-
212
- private async push<T>(
213
- topic: Channel,
214
- event: string,
215
- payload: object,
216
- ): Promise<T> {
217
- return new Promise((res, rej) => {
218
- topic
219
- .push(event, payload)
220
- .receive('ok', (msg: T) => {
221
- res(msg);
222
- })
223
- .receive('error', (reason: string) => {
224
- rej(reason);
225
- })
226
- .receive('timeout', () => {
227
- rej('timeout');
228
- });
229
- });
230
- }
231
-
232
- private params(): TVLabsSocketParams {
233
- const serviceInfo = getServiceInfo();
234
-
235
- this.log.debug('Info:', serviceInfo);
236
-
237
- return {
238
- ...serviceInfo,
239
- api_key: this.key,
240
- };
241
- }
242
-
243
- private reconnectAfterMs(tries: number) {
244
- if (tries > this.maxReconnectRetries) {
245
- throw new SevereServiceError(
246
- 'Could not connect to TV Labs, please check your connection.',
247
- );
248
- }
249
-
250
- const wait = [0, 1000, 3000, 5000][tries] || 10000;
251
-
252
- this.log.info(
253
- `[${tries}/${this.maxReconnectRetries}] Waiting ${wait}ms before re-attempting to connect...`,
254
- );
255
-
256
- return wait;
257
- }
258
-
259
186
  private tvlabsSessionLink(sessionId: string) {
260
187
  return `https://tvlabs.ai/app/sessions/${sessionId}`;
261
188
  }
262
-
263
- private static logSocketError(
264
- log: Logger,
265
- event: ErrorEvent,
266
- _transport: new (endpoint: string) => object,
267
- _establishedConnections: number,
268
- ) {
269
- log.error('Socket error:', event.error);
270
- }
271
189
  }
package/src/service.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { SevereServiceError } from 'webdriverio';
2
2
  import * as crypto from 'crypto';
3
3
 
4
- import { TVLabsChannel } from './channel.js';
4
+ import { SessionChannel } from './channels/session.js';
5
+ import { BuildChannel } from './channels/build.js';
5
6
  import { Logger } from './logger.js';
6
7
 
7
8
  import type { Services, Capabilities, Options } from '@wdio/types';
@@ -42,21 +43,41 @@ export default class TVLabsService implements Services.ServiceInstance {
42
43
  _specs: string[],
43
44
  _cid: string,
44
45
  ) {
45
- const channel = new TVLabsChannel(
46
- this.endpoint(),
46
+ const buildPath = this.buildPath();
47
+
48
+ if (buildPath) {
49
+ const buildChannel = new BuildChannel(
50
+ this.buildEndpoint(),
51
+ this.reconnectRetries(),
52
+ this.apiKey(),
53
+ this.logLevel(),
54
+ );
55
+
56
+ await buildChannel.connect();
57
+
58
+ capabilities['tvlabs:build'] = await buildChannel.uploadBuild(
59
+ buildPath,
60
+ this.appSlug(),
61
+ );
62
+
63
+ await buildChannel.disconnect();
64
+ }
65
+
66
+ const sessionChannel = new SessionChannel(
67
+ this.sessionEndpoint(),
47
68
  this.reconnectRetries(),
48
69
  this.apiKey(),
49
70
  this.logLevel(),
50
71
  );
51
72
 
52
- await channel.connect();
73
+ await sessionChannel.connect();
53
74
 
54
- capabilities['tvlabs:session_id'] = await channel.newSession(
75
+ capabilities['tvlabs:session_id'] = await sessionChannel.newSession(
55
76
  capabilities,
56
77
  this.retries(),
57
78
  );
58
79
 
59
- await channel.disconnect();
80
+ await sessionChannel.disconnect();
60
81
  }
61
82
 
62
83
  private setupRequestId() {
@@ -101,8 +122,20 @@ export default class TVLabsService implements Services.ServiceInstance {
101
122
  }
102
123
  }
103
124
 
104
- private endpoint(): string {
105
- return this._options.endpoint ?? 'wss://tvlabs.ai/appium';
125
+ private buildPath(): string | undefined {
126
+ return this._options.buildPath;
127
+ }
128
+
129
+ private appSlug(): string | undefined {
130
+ return this._options.app;
131
+ }
132
+
133
+ private sessionEndpoint(): string {
134
+ return this._options.sessionEndpoint ?? 'wss://tvlabs.ai/appium';
135
+ }
136
+
137
+ private buildEndpoint(): string {
138
+ return this._options.buildEndpoint ?? 'wss://tvlabs.ai/cli';
106
139
  }
107
140
 
108
141
  private retries(): number {
package/src/types.ts CHANGED
@@ -4,8 +4,11 @@ export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
4
4
 
5
5
  export type TVLabsServiceOptions = {
6
6
  apiKey: string;
7
- endpoint?: string;
7
+ sessionEndpoint?: string;
8
+ buildEndpoint?: string;
8
9
  retries?: number;
10
+ buildPath?: string;
11
+ app?: string;
9
12
  reconnectRetries?: number;
10
13
  attachRequestId?: boolean;
11
14
  };
@@ -49,3 +52,21 @@ export type TVLabsServiceInfo = {
49
52
  service_version: string;
50
53
  service_name: string;
51
54
  };
55
+
56
+ export type TVLabsRequestUploadUrlResponse = {
57
+ url: string;
58
+ build_id: string;
59
+ existing: boolean;
60
+ application_id?: string;
61
+ };
62
+
63
+ export type TVLabsExtractBuildInfoResponse = {
64
+ application_id: string;
65
+ };
66
+
67
+ export type TVLabsBuildMetadata = {
68
+ filename: string;
69
+ type: string;
70
+ size: number;
71
+ sha256: string;
72
+ };
@@ -1 +0,0 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,kBAAkB,EAIlB,QAAQ,EACT,MAAM,YAAY,CAAC;AAGpB,qBAAa,aAAa;IAgBtB,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,mBAAmB;IAC3B,OAAO,CAAC,GAAG;IACX,OAAO,CAAC,QAAQ;IAlBlB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAAU;IAC5B,OAAO,CAAC,YAAY,CAAC,CAAU;IAC/B,OAAO,CAAC,GAAG,CAAS;IAEpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAOZ;gBAGD,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,MAAM,EAC3B,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,QAAiB;IAgB/B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ3B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBxB,UAAU,CACd,YAAY,EAAE,kBAAkB,EAChC,UAAU,EAAE,MAAM,EAClB,KAAK,SAAI,GACR,OAAO,CAAC,MAAM,CAAC;YAWJ,WAAW;YAkBX,cAAc;IAsD5B,OAAO,CAAC,gBAAgB;YAUV,cAAc;YAuBd,IAAI;YAgBJ,IAAI;IAoBlB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,MAAM,CAAC,cAAc;CAQ9B"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,kBAAkB,EAIlB,QAAQ,EACT,MAAM,YAAY,CAAC;AAGpB,qBAAa,aAAa;IAgBtB,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,mBAAmB;IAC3B,OAAO,CAAC,GAAG;IACX,OAAO,CAAC,QAAQ;IAlBlB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAAU;IAC5B,OAAO,CAAC,YAAY,CAAC,CAAU;IAC/B,OAAO,CAAC,GAAG,CAAS;IAEpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAOZ;gBAGD,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,MAAM,EAC3B,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,QAAiB;IAgB/B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ3B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBxB,UAAU,CACd,YAAY,EAAE,kBAAkB,EAChC,UAAU,EAAE,MAAM,EAClB,KAAK,SAAI,GACR,OAAO,CAAC,MAAM,CAAC;YAWJ,WAAW;YAkBX,cAAc;IAsD5B,OAAO,CAAC,gBAAgB;YAUV,cAAc;YAuBd,IAAI;YAgBJ,IAAI;IAoBlB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,MAAM,CAAC,cAAc;CAQ9B"}