• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / roku-test-automation / 26661418474

29 May 2026 08:48PM UTC coverage: 58.876%. Remained the same
26661418474

Pull #165

github

web-flow
Merge 01f7754fe into dfb794e5f
Pull Request #165: Project restructure

341 of 592 branches covered (57.6%)

Branch coverage included in aggregate %.

2 of 11 new or added lines in 1 file covered. (18.18%)

382 of 636 relevant lines covered (60.06%)

116.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

29.35
/src/utils.ts
1
import type * as fsExtra from 'fs-extra';
2
import type * as path from 'path';
3
import type * as Mocha from 'mocha';
4
import * as Ajv from 'ajv';
1✔
5
const ajv = new Ajv();
1✔
6

7
import type { ConfigOptions, DeviceConfigOptions } from './types/ConfigOptions';
8
import type { BoundingRect } from './types/OnDeviceComponent';
9
import type { AppUIResponse, AppUIResponseChild } from './types/AppUIResponse';
10
type PathType = typeof path;
11

12
class Utils {
13
        private path?: PathType;
14

15
        private fsExtra?: typeof fsExtra;
16

17
        // We use a dynamic require to avoid an issue in the brightscript vscode extension
18
        private require<T>(id: string): T {
19
                return require(id) as T;
2✔
20
        }
21

22
        private getPath() {
23
                if (!this.path) {
8✔
24
                        this.path = this.require<PathType>('path');
1✔
25
                }
26
                return this.path;
8✔
27
        }
28

29
        public getFsExtra() {
30
                if (!this.fsExtra) {
112✔
31
                        this.fsExtra = this.require<typeof fsExtra>('fs-extra');
1✔
32
                }
33
                return this.fsExtra;
112✔
34
        }
35

36
        /** Provides a way to easily get a path to device files for external access */
37
        public getDeviceFilesPath() {
NEW
38
                return this.getPath().resolve(__dirname + '/../device');
×
39
        }
40

41
        /** Provides a way to easily get a path to client files for external access */
42
        public getClientFilesPath() {
43
                return this.getPath().resolve(__dirname + '/../');
×
44
        }
45

46
        public parseJsonFile(filePath: string) {
47
                return JSON.parse(this.getFsExtra().readFileSync(filePath, 'utf-8'));
110✔
48
        }
49

50
        public getMatchingDevices(config: ConfigOptions, deviceSelector: Record<string, any>): { [key: string]: DeviceConfigOptions } {
51
                const matchingDevices = {};
×
52
                config.RokuDevice.devices.forEach((device, index) => {
×
53
                        for (const key in deviceSelector) {
×
54
                                if (!device.properties) {
×
55
                                        continue;
×
56
                                }
57
                                const requestedValue = deviceSelector[key];
×
58
                                if (device.properties[key] !== requestedValue) continue;
×
59
                        }
60
                        matchingDevices[index] = device;
×
61
                });
62

63
                return matchingDevices;
×
64
        }
65

66
        public getConfigFromConfigFile(configFilePath = 'rta-config.json') {
×
67
                const config = this.getConfigFromConfigFileCore(configFilePath);
3✔
68
                this.validateRTAConfigSchema(config);
1✔
69

70
                return config;
1✔
71
        }
72

73
        private getConfigFromConfigFileCore(configFilePath = 'rta-config.json', parentConfigPaths: string[] = []) {
3!
74
                configFilePath = this.getPath().resolve(configFilePath);
4✔
75
                let config: ConfigOptions;
76
                try {
4✔
77
                        config = this.parseJsonFile(configFilePath);
4✔
78
                } catch (e) {
79
                        throw utils.makeError('NoConfigFound', `Config could not be found or parsed correctly: '${configFilePath}`);
1✔
80
                }
81
                parentConfigPaths.push(configFilePath);
3✔
82

83
                if (config.extends) {
3✔
84
                        //always resolve the "extends" path relative to the config file that is doing the extending
85
                        const baseConfigFilePath = this.getPath().resolve(
2✔
86
                                this.getPath().dirname(configFilePath),
87
                                config.extends
88
                        );
89
                        if (parentConfigPaths.includes(baseConfigFilePath)) {
2✔
90
                                throw new Error(`Circular dependency detected. '${baseConfigFilePath}' has already been included`);
1✔
91
                        }
92

93
                        const baseConfig = this.getConfigFromConfigFileCore(baseConfigFilePath, parentConfigPaths);
1✔
94

95
                        for (const section of ['RokuDevice', 'ECP', 'OnDeviceComponent', 'NetworkProxy', 'NetworkProxy']) {
1✔
96
                                // Override every field that was specified in the child
97
                                for (const key in config[section]) {
5✔
98
                                        if (!baseConfig[section]) {
1!
99
                                                baseConfig[section] = {};
×
100
                                        }
101
                                        baseConfig[section][key] = config[section][key];
1✔
102
                                }
103
                        }
104
                        config = baseConfig;
1✔
105
                }
106

107
                return config;
2✔
108
        }
109

110
        /** Helper for setting up process.env from a config */
111
        setupEnvironmentFromConfig(config: ConfigOptions, deviceSelector?: Record<string, any> | number) {
112
                if (deviceSelector === undefined) {
×
113
                        deviceSelector = config.RokuDevice.deviceIndex ?? 0;
×
114
                }
115

116
                if (typeof deviceSelector === 'number') {
×
117
                        config.RokuDevice.deviceIndex = deviceSelector;
×
118
                } else {
119
                        const matchingDevices = this.getMatchingDevices(config, deviceSelector);
×
120
                        const keys = Object.keys(matchingDevices);
×
121
                        if (keys.length === 0) {
×
122
                                throw utils.makeError('NoMatchingDevicesFound', 'No devices matched the device selection criteria');
×
123
                        }
124
                        config.RokuDevice.deviceIndex = parseInt(keys[0]);
×
125
                }
126
                process.env.rtaConfig = JSON.stringify(config);
×
127
        }
128

129
        /** Helper for setting up process.env from a config file */
130
        public setupEnvironmentFromConfigFile(configFilePath = 'rta-config.json', deviceSelector: Record<string, any> | number | undefined = undefined) {
×
131
                const config = this.getConfigFromConfigFile(configFilePath);
×
132
                this.setupEnvironmentFromConfig(config, deviceSelector);
×
133
        }
134

135
        /** Validates the ConfigOptions schema the current class is using
136
         * @param sectionsToValidate - if non empty array will only validate the sections provided instead of the whole schema
137
         */
138
        public validateRTAConfigSchema(config: any) {
139
                const schema = utils.parseJsonFile(__dirname + '/../rta-config.schema.json');
107✔
140
                if (!ajv.validate(schema, config)) {
107!
141
                        const error = ajv.errors?.[0];
×
142
                        throw utils.makeError('ConfigValidationError', `${error?.dataPath} ${error?.message}`);
×
143
                }
144
        }
145

146
        public getConfigFromEnvironmentOrConfigFile(configFilePath = 'rta-config.json') {
×
147
                let config = this.getOptionalConfigFromEnvironment();
×
148

149
                if (!config) {
×
150
                        config = this.getConfigFromConfigFile(configFilePath);
×
151
                }
152

153
                return config;
×
154
        }
155

156
        public getConfigFromEnvironment() {
157
                const config = this.getOptionalConfigFromEnvironment();
×
158

159
                if (!config) {
×
160
                        throw this.makeError('MissingEnvironmentError', 'Did not contain config at "process.env.rtaConfig"');
×
161
                }
162

163
                return config;
×
164
        }
165

166
        public getOptionalConfigFromEnvironment() {
167
                if (!process.env.rtaConfig) return undefined;
×
168
                const config: ConfigOptions = JSON.parse(process.env.rtaConfig);
×
169

170
                if (config) {
×
171
                        this.validateRTAConfigSchema(config);
×
172
                }
173

174
                return config;
×
175
        }
176

177
        public sleep(milliseconds: number) {
178
                return new Promise((resolve) => setTimeout(resolve, milliseconds));
×
179
        }
180

181
        public async promiseTimeout<T>(promise: Promise<T>, milliseconds: number, message?: string) {
182
                // IMPROVEMENT capture starting line in the same way we do for ODC requests
183
                let timeout;
184
                const timeoutPromise = new Promise<T>((resolve, reject) => {
×
185
                        timeout = setTimeout(() => {
×
186
                                if (!message) {
×
187
                                        message = 'Timed out after ' + milliseconds + 'ms.';
×
188
                                }
189
                                reject(this.makeError('Timeout', message));
×
190
                        }, milliseconds);
191
                });
192

193
                // Returns a race between our timeout and the passed in promise
194
                try {
×
195
                        return await Promise.race([
×
196
                                promise,
197
                                timeoutPromise
198
                        ]);
199
                } finally {
200
                        clearTimeout(timeout);
×
201
                }
202
        }
203

204
        public makeError(name: string, message: string) {
205
                const error = new Error(message);
488✔
206
                error.name = name;
488✔
207
                return error;
488✔
208
        }
209

210
        public getTestTitlePath(contextOrSuite: Mocha.Context | Mocha.Suite, sanitize = true) {
13✔
211
                let ctx: Mocha.Context;
212
                if (contextOrSuite.constructor.name === 'Context') {
15✔
213
                        ctx = contextOrSuite as Mocha.Context;
2✔
214
                } else if (contextOrSuite.constructor.name === 'Suite') {
13!
215
                        ctx = contextOrSuite.ctx as Mocha.Context;
13✔
216
                } else {
217
                        throw new Error('Neither Mocha.Context or Mocha.Suite passed in');
×
218
                }
219

220
                let test: Mocha.Runnable;
221
                if (ctx.currentTest?.constructor.name === 'Test') {
15!
222
                        test = ctx.currentTest;
×
223
                } else if (ctx.test?.constructor.name === 'Test') {
15!
224
                        test = ctx.test;
15✔
225
                } else {
226
                        throw new Error('Mocha.Context did not contain test. At least surrounding Mocha.Suite must use non arrow function');
×
227
                }
228

229
                const pathParts = test.titlePath();
15✔
230
                if (sanitize) {
15✔
231
                        for (const [index, pathPart] of pathParts.entries()) {
14✔
232
                                if (sanitize) {
42!
233
                                        pathParts[index] = pathPart.replace(/[^a-zA-Z0-9_]/g, '_');
42✔
234
                                } else {
235
                                        pathParts[index] = pathPart;
×
236
                                }
237

238
                        }
239

240
                }
241
                return pathParts;
15✔
242
        }
243

244
        public generateFileNameForTest(contextOrSuite: Mocha.Context | Mocha.Suite, extension: string, postFix = '', separator = '_') {
×
245
                const titlePath = this.getTestTitlePath(contextOrSuite);
13✔
246
                return titlePath.join(separator) + postFix + `.${extension}`;
13✔
247
        }
248

249
        public async ensureDirExistForFilePath(filePath: string) {
250
                await this.getFsExtra().ensureDir(this.getPath().dirname(filePath));
×
251
        }
252

253
        public randomStringGenerator(length = 7) {
1✔
254
                const p = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
1✔
255
                return [...Array(length)].reduce((a) => a + p[~~(Math.random() * p.length)], '');
7✔
256
        }
257

258
        public addRandomPostfix(message: string, length = 2) {
×
259
                return `${message}-${this.randomStringGenerator(length)}`;
×
260
        }
261

262
        public isObjectWithProperty<Y extends PropertyKey>
263
                (obj: any, prop: Y): obj is Record<Y, unknown> {
NEW
264
                if (obj === null || typeof obj !== 'object') {
×
NEW
265
                        return false;
×
266
                }
267
                // eslint-disable-next-line no-prototype-builtins
NEW
268
                return obj.hasOwnProperty(prop);
×
269
        }
270

271
        public convertValueToNumber(value: string | number | undefined, defaultValue = 0) {
4✔
272
                if (typeof value === 'number') {
4!
273
                        return value;
×
274
                } else if (typeof value === 'string') {
4!
275
                        return +value;
4✔
276
                }
277
                return defaultValue;
×
278
        }
279

280
        public lpad(value, padLength = 2, padCharacter = '0') {
×
281
                return value.toString().padStart(padLength, padCharacter);
×
282
        }
283

284
        public randomInteger(max = 2147483647, min = 0) {
×
NEW
285
                return Math.floor(Math.random() * (max - min + 1)) + min;
×
286
        }
287

288
        public findNodesAtLocation(args: { appUIResponse: AppUIResponse, x: number, y: number, includeMatchesWithoutKeyPath?: boolean }) {
289
                const matches = [] as AppUIResponseChild[];
×
NEW
290
                this.findNodesAtLocationCore({ ...args, children: args.appUIResponse.screen.children, matches: matches });
×
291

NEW
292
                const { x, y } = args;
×
293

294
                // We now want to sort our matches to try and return the best one first
295
                matches.sort((a, b) => {
×
296
                        const aOffest = this.calculateRectCenterPointOffsetFromLocation(x, y, a.sceneRect as BoundingRect);
×
297
                        const bOffset = this.calculateRectCenterPointOffsetFromLocation(x, y, b.sceneRect as BoundingRect);
×
298
                        const difference = (Math.abs(aOffest.x) + Math.abs(aOffest.y)) - (Math.abs(bOffset.x) + Math.abs(bOffset.y));
×
299
                        if (difference === 0) {
×
300
                                // If both items have the exact same size and position then we want to return the deepest one.
301
                                if (a.keyPath && b.keyPath) {
×
302
                                        const aKeyPath = a.keyPath.split('.');
×
303
                                        const bKeyPath = b.keyPath.split('.');
×
304
                                        if (aKeyPath.length < bKeyPath.length) {
×
305
                                                return difference + .1;
×
306
                                        } else if (aKeyPath.length > bKeyPath.length) {
×
307
                                                return difference - .1;
×
308
                                        }
309
                                } else if (a.keyPath) {
×
310
                                        return difference - .1;
×
311
                                } else {
312
                                        return difference + .1;
×
313
                                }
314
                        }
315
                        return difference;
×
316
                });
317

318
                return {
×
319
                        matches
320
                };
321
        }
322

323
        private findNodesAtLocationCore(args: { x: number, y: number, children: AppUIResponseChild[], matches: AppUIResponseChild[], isArrayGridChild?: boolean, includeMatchesWithoutKeyPath?: boolean }) {
324
                let isArrayGridChild = args.isArrayGridChild ?? false;
×
325

NEW
326
                const { x, y, children, matches } = args;
×
327

328
                for (const child of children) {
×
329
                        let isLocationWithinNodeDimensions = false;
×
330
                        if (child.sceneRect) {
×
331
                                const rect = child.sceneRect;
×
332
                                isLocationWithinNodeDimensions = (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height);
×
333
                        }
334
                        const isVisible = (child.visible && !!child.opacity);
×
335

336
                        if (child.subtype == 'RowListItem') {
×
337
                                isArrayGridChild = true;
×
338
                        }
339

340
                        if ((isLocationWithinNodeDimensions && isVisible) || isArrayGridChild) {
×
341
                                if (isLocationWithinNodeDimensions && isVisible) {
×
342
                                        if (child.keyPath != undefined || args.includeMatchesWithoutKeyPath) {
×
343
                                                matches.push(child);
×
344
                                        }
345
                                }
346

347
                                if (child.children?.length) {
×
NEW
348
                                        this.findNodesAtLocationCore({ x: x, y: y, children: child.children, matches: matches, isArrayGridChild: isArrayGridChild });
×
349
                                }
350
                        }
351
                }
352
        }
353

354
        private calculateRectCenterPoint(rect: BoundingRect) {
355
                return {
×
356
                        x: (rect.x - rect.width) / 2,
357
                        y: (rect.y - rect.height) / 2
358
                };
359
        }
360

361
        private calculateRectCenterPointOffsetFromLocation(x: number, y: number, rect: BoundingRect) {
362
                const centerPoint = this.calculateRectCenterPoint(rect);
×
363
                return {
×
364
                        x: x - centerPoint.x,
365
                        y: y - centerPoint.y
366
                };
367
        }
368
}
369

370
const utils = new Utils();
1✔
371
export { utils };
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc