@tvlabs/wdio-service 0.1.4 → 0.1.6

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,110 @@
1
+ import { WebSocket } from 'ws';
2
+ import { Socket, type Channel } from 'phoenix';
3
+ import { SevereServiceError } from 'webdriverio';
4
+ import { Logger } from '../logger.js';
5
+ import { getServiceInfo } from '../utils.js';
6
+
7
+ import type { TVLabsSocketParams, LogLevel } from '../types.js';
8
+ import type { PhoenixChannelJoinResponse } from '../phoenix.js';
9
+
10
+ export abstract class BaseChannel {
11
+ protected socket: Socket;
12
+ protected log: Logger;
13
+
14
+ constructor(
15
+ protected endpoint: string,
16
+ protected maxReconnectRetries: number,
17
+ protected key: string,
18
+ protected logLevel: LogLevel = 'info',
19
+ loggerName: string,
20
+ ) {
21
+ this.log = new Logger(loggerName, this.logLevel);
22
+
23
+ this.socket = new Socket(this.endpoint, {
24
+ transport: WebSocket,
25
+ params: this.params(),
26
+ reconnectAfterMs: this.reconnectAfterMs.bind(this),
27
+ });
28
+
29
+ this.socket.onError((...args) =>
30
+ BaseChannel.logSocketError(this.log, ...args),
31
+ );
32
+ }
33
+
34
+ abstract connect(): Promise<void>;
35
+ abstract disconnect(): Promise<void>;
36
+
37
+ protected async join(topic: Channel): Promise<void> {
38
+ return new Promise((res, rej) => {
39
+ topic
40
+ .join()
41
+ .receive('ok', (_resp: PhoenixChannelJoinResponse) => {
42
+ res();
43
+ })
44
+ .receive('error', ({ response }: PhoenixChannelJoinResponse) => {
45
+ rej('Failed to join topic: ' + response);
46
+ })
47
+ .receive('timeout', () => {
48
+ rej('timeout');
49
+ });
50
+ });
51
+ }
52
+
53
+ protected async push<T>(
54
+ topic: Channel,
55
+ event: string,
56
+ payload: object,
57
+ ): Promise<T> {
58
+ return new Promise((res, rej) => {
59
+ topic
60
+ .push(event, payload)
61
+ .receive('ok', (msg: T) => {
62
+ res(msg);
63
+ })
64
+ .receive('error', (reason: string) => {
65
+ rej(reason);
66
+ })
67
+ .receive('timeout', () => {
68
+ rej('timeout');
69
+ });
70
+ });
71
+ }
72
+
73
+ private params(): TVLabsSocketParams {
74
+ const serviceInfo = getServiceInfo();
75
+
76
+ this.log.debug('Info:', serviceInfo);
77
+
78
+ return {
79
+ ...serviceInfo,
80
+ api_key: this.key,
81
+ };
82
+ }
83
+
84
+ private reconnectAfterMs(tries: number) {
85
+ if (tries > this.maxReconnectRetries) {
86
+ throw new SevereServiceError(
87
+ 'Could not connect to TV Labs, please check your connection.',
88
+ );
89
+ }
90
+
91
+ const wait = [0, 1000, 3000, 5000][tries] || 10000;
92
+
93
+ this.log.info(
94
+ `[${tries}/${this.maxReconnectRetries}] Waiting ${wait}ms before re-attempting to connect...`,
95
+ );
96
+
97
+ return wait;
98
+ }
99
+
100
+ private static logSocketError(
101
+ log: Logger,
102
+ event: ErrorEvent,
103
+ _transport: new (endpoint: string) => object,
104
+ _establishedConnections: number,
105
+ ) {
106
+ const error = event.error;
107
+
108
+ log.error('Socket error:', error || event);
109
+ }
110
+ }
@@ -0,0 +1,174 @@
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 { url, build_id } = await this.requestUploadUrl(metadata, appSlug);
67
+
68
+ this.log.info('Uploading build...');
69
+
70
+ await this.uploadToUrl(url, buildPath, metadata);
71
+
72
+ const { application_id } = await this.extractBuildInfo();
73
+
74
+ this.log.info(`Build "${application_id}" processed successfully`);
75
+
76
+ return build_id;
77
+ }
78
+
79
+ private async requestUploadUrl(
80
+ metadata: TVLabsBuildMetadata,
81
+ appSlug: string | undefined,
82
+ ) {
83
+ try {
84
+ return await this.push<TVLabsRequestUploadUrlResponse>(
85
+ this.lobbyTopic,
86
+ 'request_upload_url',
87
+ { metadata, application_slug: appSlug },
88
+ );
89
+ } catch (error) {
90
+ this.log.error('Error requesting upload URL:', error);
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ private async uploadToUrl(
96
+ url: string,
97
+ filePath: string,
98
+ metadata: TVLabsBuildMetadata,
99
+ ): Promise<void> {
100
+ try {
101
+ const response = await fetch(url, {
102
+ method: 'PUT',
103
+ headers: {
104
+ 'Content-Type': metadata.type,
105
+ 'Content-Length': String(metadata.size),
106
+ },
107
+ // @ts-expect-error - FIXME: fetch types are incorrect
108
+ body: fs.createReadStream(filePath),
109
+ duplex: 'half',
110
+ });
111
+
112
+ if (!response.ok) {
113
+ throw new SevereServiceError(
114
+ `Failed to upload build to storage, got ${response.status}`,
115
+ );
116
+ }
117
+
118
+ this.log.info('Upload complete');
119
+ } catch (error) {
120
+ this.log.error('Error uploading build:', error);
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ private async extractBuildInfo() {
126
+ this.log.info('Processing uploaded build...');
127
+
128
+ try {
129
+ return await this.push<TVLabsExtractBuildInfoResponse>(
130
+ this.lobbyTopic,
131
+ 'extract_build_info',
132
+ {},
133
+ );
134
+ } catch (error) {
135
+ this.log.error('Error processing build:', error);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ private async getFileMetadata(
141
+ buildPath: string,
142
+ ): Promise<TVLabsBuildMetadata> {
143
+ const filename = path.basename(buildPath);
144
+ const size = fs.statSync(buildPath).size;
145
+ const type = this.detectMimeType(filename);
146
+ const sha256 = await this.computeSha256(buildPath);
147
+
148
+ return { filename, type, size, sha256 };
149
+ }
150
+
151
+ private async computeSha256(buildPath: string): Promise<string> {
152
+ return new Promise((resolve, reject) => {
153
+ const hash = crypto.createHash('sha256');
154
+ const stream = fs.createReadStream(buildPath);
155
+
156
+ stream.on('data', (chunk) => hash.update(chunk));
157
+ stream.on('end', () => resolve(hash.digest('hex')));
158
+ stream.on('error', (err) => reject(err));
159
+ });
160
+ }
161
+
162
+ private detectMimeType(filename: string): string {
163
+ const fileExtension = path.extname(filename).toLowerCase();
164
+
165
+ switch (fileExtension) {
166
+ case '.apk':
167
+ return 'application/vnd.android.package-archive';
168
+ case '.zip':
169
+ return 'application/zip';
170
+ default:
171
+ return 'application/octet-stream';
172
+ }
173
+ }
174
+ }
@@ -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,81 +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
- const error = event.error;
270
-
271
- log.error('Socket error:', error || event);
272
- }
273
189
  }
package/src/logger.ts CHANGED
@@ -28,12 +28,69 @@ export class Logger {
28
28
  private formatMessage(level: LogLevel, ...args: unknown[]): string {
29
29
  const timestamp = new Date().toISOString();
30
30
  return `${timestamp} ${level.toUpperCase()} ${this.name}: ${args
31
- .map((arg) =>
32
- typeof arg === 'object' ? JSON.stringify(arg) : String(arg),
33
- )
31
+ .map((arg) => this.serializeArg(arg))
34
32
  .join(' ')}`;
35
33
  }
36
34
 
35
+ private serializeArg(arg: unknown): string {
36
+ if (
37
+ typeof arg === 'string' ||
38
+ typeof arg === 'number' ||
39
+ typeof arg === 'boolean'
40
+ ) {
41
+ return String(arg);
42
+ }
43
+
44
+ if (arg === null || arg === undefined) {
45
+ return String(arg);
46
+ }
47
+
48
+ if (arg instanceof Error) {
49
+ // Handle Error objects specially - use stack trace which includes name and message
50
+ return arg.stack || `${arg.name}: ${arg.message}`;
51
+ }
52
+
53
+ if (typeof arg === 'object') {
54
+ try {
55
+ // Try JSON.stringify with a custom replacer to handle nested Error objects
56
+ const stringified = JSON.stringify(arg, (key, value) => {
57
+ if (value instanceof Error) {
58
+ return `${value.name}: ${value.message}`;
59
+ }
60
+ return value;
61
+ });
62
+
63
+ // If it's just an empty object, try to extract more info
64
+ if (stringified === '{}') {
65
+ // For objects that don't serialize well, try to extract key properties
66
+ const keys = Object.getOwnPropertyNames(arg);
67
+ if (keys.length > 0) {
68
+ const props: Record<string, unknown> = {};
69
+ keys.forEach((key) => {
70
+ try {
71
+ const value = (arg as Record<string, unknown>)[key];
72
+ if (value instanceof Error) {
73
+ props[key] = `${value.name}: ${value.message}`;
74
+ } else {
75
+ props[key] = value;
76
+ }
77
+ } catch {
78
+ props[key] = '[unable to access]';
79
+ }
80
+ });
81
+ return JSON.stringify(props);
82
+ }
83
+ }
84
+ return stringified;
85
+ } catch {
86
+ // Fallback to string representation if JSON.stringify fails
87
+ return String(arg);
88
+ }
89
+ }
90
+
91
+ return String(arg);
92
+ }
93
+
37
94
  debug(...args: unknown[]): void {
38
95
  if (this.shouldLog('debug')) {
39
96
  console.log(this.formatMessage('debug', ...args));
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,19 @@ 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
+ };
60
+
61
+ export type TVLabsExtractBuildInfoResponse = {
62
+ application_id: string;
63
+ };
64
+
65
+ export type TVLabsBuildMetadata = {
66
+ filename: string;
67
+ type: string;
68
+ size: number;
69
+ sha256: string;
70
+ };
@@ -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;CAU9B"}
@@ -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;CAU9B"}