-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathtest_setup.mjs
More file actions
590 lines (549 loc) · 18.7 KB
/
test_setup.mjs
File metadata and controls
590 lines (549 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Node.js script to run automated functional tests in
* Chrome, via webdriver.
*
* This file is to be used in the suiteSetup for any automated fuctional test.
*
* Note: In this file many functions return browser elements that can
* be clicked or otherwise interacted with through Selenium WebDriver. These
* elements are not the raw HTML and SVG elements on the page; they are
* identifiers that Selenium can use to find those elements.
*/
import * as path from 'path';
import {fileURLToPath} from 'url';
import * as webdriverio from 'webdriverio';
import {posixPath} from '../../../scripts/helpers.js';
let driver = null;
/**
* The default amount of time to wait during a test. Increase this to make
* tests easier to watch; decrease it to make tests run faster.
*/
export const PAUSE_TIME = 50;
/**
* Start up the test page. This should only be done once, to avoid
* constantly popping browser windows open and closed.
* @return A Promsie that resolves to a webdriverIO browser that tests can manipulate.
*/
export async function driverSetup() {
const options = {
capabilities: {
'browserName': 'chrome',
'unhandledPromptBehavior': 'ignore',
'goog:chromeOptions': {
args: ['--allow-file-access-from-files'],
},
},
logLevel: 'warn',
};
// Run in headless mode on Github Actions.
if (process.env.CI) {
options.capabilities['goog:chromeOptions'].args.push(
'--headless',
'--no-sandbox',
'--disable-dev-shm-usage',
);
} else {
// --disable-gpu is needed to prevent Chrome from hanging on Linux with
// NVIDIA drivers older than v295.20. See
// https://github.com/google/blockly/issues/5345 for details.
options.capabilities['goog:chromeOptions'].args.push('--disable-gpu');
}
// Use Selenium to bring up the page
console.log('Starting webdriverio...');
driver = await webdriverio.remote(options);
return driver;
}
/**
* End the webdriverIO session.
* @return A Promise that resolves after the actions have been completed.
*/
export async function driverTeardown() {
await driver.deleteSession();
driver = null;
return;
}
/**
* Navigate to the correct URL for the test, using the shared driver.
* @param {string} playgroundUrl The URL to open for the test, which should be
* a Blockly playground with a workspace.
* @return A Promsie that resolves to a webdriverIO browser that tests can manipulate.
*/
export async function testSetup(playgroundUrl) {
if (!driver) {
await driverSetup();
}
await driver.url(playgroundUrl);
// Wait for the workspace to exist and be rendered.
await driver
.$('.blocklySvg .blocklyWorkspace > .blocklyBlockCanvas')
.waitForExist({timeout: 2000});
return driver;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const testFileLocations = {
BLOCK_FACTORY:
'file://' +
posixPath(path.join(__dirname, '..', '..', '..', 'demos', 'blockfactory')) +
'/index.html',
CODE_DEMO:
'file://' +
posixPath(path.join(__dirname, '..', '..', '..', 'demos', 'code')) +
'/index.html',
PLAYGROUND:
'file://' +
posixPath(path.join(__dirname, '..', '..')) +
'/playground.html',
PLAYGROUND_RTL:
'file://' +
posixPath(path.join(__dirname, '..', '..')) +
'/playground.html?dir=rtl',
};
/**
* Enum for both LTR and RTL use cases.
*
* @readonly
* @enum {number}
*/
export const screenDirection = {
RTL: -1,
LTR: 1,
};
/**
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves to the ID of the currently selected block.
*/
export async function getSelectedBlockId(browser) {
return await browser.execute(() => {
// Note: selected is an ICopyable and I am assuming that it is a BlockSvg.
return Blockly.common.getSelected()?.id;
});
}
/**
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves to the selected block's root SVG element,
* as an interactable browser element.
*/
export async function getSelectedBlockElement(browser) {
const id = await getSelectedBlockId(browser);
return getBlockElementById(browser, id);
}
/**
* @param browser The active WebdriverIO Browser object.
* @param id The ID of the Blockly block to search for.
* @return A Promise that resolves to the root SVG element of the block with
* the given ID, as an interactable browser element.
*/
export async function getBlockElementById(browser, id) {
const elem = await browser.$(`[data-id="${id}"]`);
elem['id'] = id;
return elem;
}
/**
* Find a clickable element on the block and click it.
* We can't always use the block's SVG root because clicking will always happen
* in the middle of the block's bounds (including children) by default, which
* causes problems if it has holes (e.g. statement inputs). Instead, this tries
* to get the first text field on the block. It falls back on the block's SVG root.
* @param browser The active WebdriverIO Browser object.
* @param block The block to click, as an interactable element.
* @param clickOptions The options to pass to webdriverio's element.click function.
* @return A Promise that resolves when the actions are completed.
*/
export async function clickBlock(browser, block, clickOptions) {
const findableId = 'clickTargetElement';
// In the browser context, find the element that we want and give it a findable ID.
await browser.execute(
(blockId, newElemId) => {
const block = Blockly.getMainWorkspace().getBlockById(blockId);
for (const input of block.inputList) {
for (const field of input.fieldRow) {
if (field instanceof Blockly.FieldLabel) {
field.getSvgRoot().id = newElemId;
return;
}
}
}
// No label field found. Fall back to the block's SVG root.
block.getSvgRoot().id = findableId;
},
block.id,
findableId,
);
// In the test context, get the Webdriverio Element that we've identified.
const elem = await browser.$(`#${findableId}`);
await elem.click(clickOptions);
// In the browser context, remove the ID.
await browser.execute((elemId) => {
const clickElem = document.getElementById(elemId);
clickElem.removeAttribute('id');
}, findableId);
}
/**
* Clicks on the svg root of the main workspace.
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves when the actions are completed.
*/
export async function clickWorkspace(browser) {
const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g');
await workspace.click();
await browser.pause(PAUSE_TIME);
}
/**
* Clicks on the svg root of the first mutator workspace found.
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves when the actions are completed.
* @throws If the mutator workspace cannot be found.
*/
export async function clickMutatorWorkspace(browser) {
const hasMutator = await browser.$('.blocklyMutatorBackground');
if (!hasMutator) {
throw new Error('No mutator workspace found');
}
const workspace = await browser
.$('.blocklyMutatorBackground')
.closest('g.blocklyWorkspace');
await workspace.click();
await browser.pause(PAUSE_TIME);
}
/**
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to find.
* @return A Promise that resolves to the root element of the toolbox
* category with the given name, as an interactable browser element.
* @throws If the category cannot be found.
*/
export async function getCategory(browser, categoryName) {
const category = browser.$(`.blocklyToolboxCategory*=${categoryName}`);
category.waitForExist();
return await category;
}
/**
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to search.
* @param n Which block to select, 0-indexed from the top of the category.
* @return A Promise that resolves to the root element of the nth
* block in the given category.
*/
export async function getNthBlockOfCategory(browser, categoryName, n) {
const category = await getCategory(browser, categoryName);
await category.click();
const block = (
await browser.$$(`.blocklyFlyout .blocklyBlockCanvas > .blocklyDraggable`)
)[n];
return block;
}
/**
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to search.
* Null if the toolbox has no categories (simple).
* @param blockType The type of the block to search for.
* @return A Promise that resolves to the root element of the first
* block with the given type in the given category.
*/
export async function getBlockTypeFromCategory(
browser,
categoryName,
blockType,
) {
if (categoryName) {
const category = await getCategory(browser, categoryName);
await category.click();
}
const id = await browser.execute((blockType) => {
return Blockly.getMainWorkspace()
.getFlyout()
.getWorkspace()
.getBlocksByType(blockType)[0].id;
}, blockType);
return getBlockElementById(browser, id);
}
/**
* @param browser The active WebdriverIO Browser object.
* @param blockType The type of the block to search for in the workspace.
* @param position The the position of the block type on the workspace.
* @return A Promise that resolves to the root element of the block with the
* given position and type on the workspace.
*/
export async function getBlockTypeFromWorkspace(browser, blockType, position) {
const id = await browser.execute(
(blockType, position) => {
return Blockly.getMainWorkspace().getBlocksByType(blockType, true)[
position
].id;
},
blockType,
position,
);
return getBlockElementById(browser, id);
}
/**
* @param browser The active WebdriverIO Browser object.
* @param id The ID of the block the connection is on.
* @param connectionName Which connection to return. An input name to
* get a value or statement connection, and otherwise the type of
* the connection.
* @param mutatorBlockId The block that holds the mutator icon or null if the target block is on the main workspace
* @return A Promise that resolves to the location of the specific
* connection in screen coordinates.
*/
async function getLocationOfBlockConnection(
browser,
id,
connectionName,
mutatorBlockId,
) {
return await browser.execute(
(id, connectionName, mutatorBlockId) => {
let block;
if (mutatorBlockId) {
block = Blockly.getMainWorkspace()
.getBlockById(mutatorBlockId)
.mutator.getWorkspace()
.getBlockById(id);
} else {
block = Blockly.getMainWorkspace().getBlockById(id);
}
let connection;
switch (connectionName) {
case 'OUTPUT':
connection = block.outputConnection;
break;
case 'PREVIOUS':
connection = block.previousConnection;
break;
case 'NEXT':
connection = block.nextConnection;
break;
default:
connection = block.getInput(connectionName).connection;
break;
}
const loc = Blockly.utils.Coordinate.sum(
block.getRelativeToSurfaceXY(),
connection.getOffsetInBlock(),
);
return Blockly.utils.svgMath.wsToScreenCoordinates(
Blockly.getMainWorkspace(),
loc,
);
},
id,
connectionName,
mutatorBlockId,
);
}
/**
* Drags a block toward another block so that the specified connections attach.
*
* @param browser The active WebdriverIO Browser object.
* @param draggedBlock The block to drag.
* @param draggedConnection The active connection on the block being dragged.
* @param targetBlock The block to drag to.
* @param targetConnection The connection to connect to on the target block.
* @param mutatorBlockId The block that holds the mutator icon or null if the
* target block is on the main workspace
* @param dragBlockSelector The selector of the block to drag
* @return A Promise that resolves when the actions are completed.
*/
export async function connect(
browser,
draggedBlock,
draggedConnection,
targetBlock,
targetConnection,
mutatorBlockId,
dragBlockSelector,
) {
const draggedLocation = await getLocationOfBlockConnection(
browser,
draggedBlock.id,
draggedConnection,
mutatorBlockId,
);
const targetLocation = await getLocationOfBlockConnection(
browser,
targetBlock.id,
targetConnection,
mutatorBlockId,
);
const delta = {
x: Math.round(targetLocation.x - draggedLocation.x),
y: Math.round(targetLocation.y - draggedLocation.y),
};
if (mutatorBlockId) {
await dragBlockSelector.dragAndDrop(delta);
} else {
await draggedBlock.dragAndDrop(delta);
}
}
/**
* Switch the playground to RTL mode.
*
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves when the actions are completed.
*/
export async function switchRTL(browser) {
const ltrForm = await browser.$('#options > select:nth-child(1)');
await ltrForm.selectByIndex(1);
await browser.pause(PAUSE_TIME + 450);
}
/**
* Drag the specified block from the flyout and return the root element
* of the block.
*
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to search.
* @param n Which block to select, indexed from the top of the category.
* @param x The x-distance to drag, as a delta from the block's
* initial location on screen.
* @param y The y-distance to drag, as a delta from the block's
* initial location on screen.
* @return A Promise that resolves to the root element of the newly
* created block.
*/
export async function dragNthBlockFromFlyout(browser, categoryName, n, x, y) {
const flyoutBlock = await getNthBlockOfCategory(browser, categoryName, n);
await flyoutBlock.dragAndDrop({x: x, y: y});
return await getSelectedBlockElement(browser);
}
/**
* Drag the specified block from the flyout and return the root element
* of the block.
*
* @param browser The active WebdriverIO Browser object.
* @param categoryName The name of the toolbox category to search.
* Null if the toolbox has no categories (simple).
* @param type The type of the block to search for.
* @param x The x-distance to drag, as a delta from the block's
* initial location on screen.
* @param y The y-distance to drag, as a delta from the block's
* initial location on screen.
* @return A Promise that resolves to the root element of the newly
* created block.
*/
export async function dragBlockTypeFromFlyout(
browser,
categoryName,
type,
x,
y,
) {
const flyoutBlock = await getBlockTypeFromCategory(
browser,
categoryName,
type,
);
await flyoutBlock.dragAndDrop({x: x, y: y});
return await getSelectedBlockElement(browser);
}
/**
* Drags the specified block type from the mutator flyout of the given block and
* returns the root element of the block.
*
* @param browser The active WebdriverIO Browser object.
* @param mutatorBlock The block with the mutator attached that we want to drag
* a block from.
* @param type The type of the block to search for.
* @param x The x-distance to drag, as a delta from the block's
* initial location on screen.
* @param y The y-distance to drag, as a delta from the block's
* initial location on screen.
* @return A Promise that resolves to the root element of the newly
* created block.
*/
export async function dragBlockFromMutatorFlyout(
browser,
mutatorBlock,
type,
x,
y,
) {
const id = await browser.execute(
(mutatorBlockId, blockType) => {
return Blockly.getMainWorkspace()
.getBlockById(mutatorBlockId)
.mutator.getWorkspace()
.getFlyout()
.getWorkspace()
.getBlocksByType(blockType)[0].id;
},
mutatorBlock.id,
type,
);
const flyoutBlock = await getBlockElementById(browser, id);
await flyoutBlock.dragAndDrop({x: x, y: y});
return await getSelectedBlockElement(browser);
}
/**
* Right-click on the specified block, then click on the specified
* context menu item.
*
* @param browser The active WebdriverIO Browser object.
* @param block The block to click, as an interactable element. This block should
* have text on it, because we use the text element as the click target.
* @param itemText The display text of the context menu item to click.
* @return A Promise that resolves when the actions are completed.
*/
export async function contextMenuSelect(browser, block, itemText) {
await clickBlock(browser, block, {button: 2});
const item = await browser.$(`div=${itemText}`);
await item.waitForExist();
await item.click();
await browser.pause(PAUSE_TIME);
}
/**
* Opens the mutator bubble for the given block.
*
* @param browser The active WebdriverIO Browser object.
* @param block The block to click, as an interactable element.
* @return A Promise that resolves when the actions are complete.
*/
export async function openMutatorForBlock(browser, block) {
const icon = await browser.$(`[data-id="${block.id}"] > g.blocklyIconGroup`);
await icon.click();
}
/**
* Get all blocks on the main workspace. Because the blocks have circular
* references that can't be JSON-encoded they can't be returned directly, so
* extract relevant properties only.
*
* @param browser The active WebdriverIO Browser object.
* @return A Promise that resolves to an array of blocks on the main workspace.
*/
export async function getAllBlocks(browser) {
return browser.execute(() => {
return Blockly.getMainWorkspace()
.getAllBlocks(false)
.map((block) => ({
type: block.type,
id: block.id,
}));
});
}
/**
* Find the flyout's scrollbar and scroll by the specified amount.
* This makes several assumptions:
* - A flyout with a valid scrollbar exists, is open, and is in view.
* - The workspace has a trash can, which means it has a second (hidden) flyout.
* @param browser The active WebdriverIO Browser object.
* @param xDelta How far to drag the flyout in the x direction. Positive is right.
* @param yDelta How far to drag the flyout in the y direction. Positive is down.
* @return A Promise that resolves when the actions are completed.
*/
export async function scrollFlyout(browser, xDelta, yDelta) {
// There are two flyouts on the playground workspace: one for the trash can
// and one for the toolbox. We want the second one.
// This assumes there is only one scrollbar handle in the flyout, but it could
// be either horizontal or vertical.
await browser.pause(PAUSE_TIME);
const scrollbarHandle = await browser
.$$(`.blocklyFlyoutScrollbar`)[1]
.$(`rect.blocklyScrollbarHandle`);
await scrollbarHandle.dragAndDrop({x: xDelta, y: yDelta});
await browser.pause(PAUSE_TIME);
}