// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import type {ElementObject} from '../../prod/file_manager/shared_types.js';
import {getCaller, pending, repeatUntil} from '../../test_util.js';
import {remoteCall} from '../background.js';
const FAKE_ENTRY_PATH_PREFIX = 'fake-entry:';
const ENTRY_LIST_PATH_PREFIX = 'entry-list:';
const REAL_ENTRY_PATH_PREFIX = 'filesystem:chrome://file-manager/external';
/** This serves as the additional selector of the tree item. */
interface ModifierOptions {
expanded?: boolean;
selected?: boolean;
focused?: boolean;
shortcut?: boolean;
renaming?: boolean;
acceptDrop?: boolean;
hasChildren?: boolean;
mayHaveChildren?: boolean;
currentDirectory?: boolean;
}
/**
* Page object for Directory Tree, this class abstracts all the selectors
* related to directory tree and its tree items.
*/
export class DirectoryTreePageObject {
/**
* Return a singleton instance of DirectoryTreePageObject. This will make sure
* the directory tree DOM element is ready.
*/
static async create(appId: string): Promise<DirectoryTreePageObject> {
const directoryTree = new DirectoryTreePageObject(appId);
await remoteCall.waitForElement(appId, directoryTree.rootSelector);
return directoryTree;
}
private selectors_: DirectoryTreeSelectors;
/**
* Note: do not use constructor directly, use `create` static method instead,
* which will fetch the `useNewTree_` value and make sure the tree DOM element
* is ready.
*/
constructor(private appId_: string) {
this.selectors_ = new DirectoryTreeSelectors();
}
/**
* Returns the selector for the tree root.
*/
get rootSelector(): string {
return this.selectors_.root;
}
/**
* Returns the selector for the tree container.
*/
get containerSelector(): string {
return this.selectors_.container;
}
/**
* Returns the selector by the tree label.
*
* @param label Label of the tree item
*/
itemSelectorByLabel(label: string): string {
return this.selectors_.itemByLabel(label);
}
/**
* Wait for the selected(aka "active" in the old tree implementation) tree
* item with the label.
*
* @param label Label of the tree item.
*/
async waitForSelectedItemByLabel(label: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_, this.selectors_.itemByLabel(label, {selected: true}));
}
/**
* Wait for the selected(aka "active" in the old tree implementation) tree
* item with the label to be lost.
*
* @param label Label of the tree item.
*/
async waitForSelectedItemLostByLabel(label: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_, this.selectors_.itemByLabel(label, {selected: true}));
}
/**
* Wait for the tree item with the label to have focused (aka "selected" in
* the old tree implementation) state.
*
* @param label Label of the tree item
*/
async waitForFocusedItemByLabel(label: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_, this.selectors_.itemByLabel(label, {focused: true}));
}
/**
* Wait for the tree item with the label to be focusable (aka "selected" in
* the old tree implementation).
*
* @param label Label of the tree item
*/
async waitForFocusableItemByLabel(label: string): Promise<ElementObject> {
return remoteCall.waitForElement(
// Go inside shadow DOM to check tabindex.
this.appId_, [this.selectors_.itemByLabel(label), 'li[tabindex="0"]']);
}
/**
* Wait for the tree item with the type to have focused (aka "selected" in the
* old tree implementation) state.
*
* @param type Type of the tree item.
*/
async waitForFocusedItemByType(type: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByType(
type, /* isPlaceholder= */ false, {focused: true}));
}
/**
* Wait for the shortcut tree item with the label to have focused (aka
* "selected" in the old tree implementation) state.
*
* @param label Label of the tree item
*/
async waitForFocusedShortcutItemByLabel(label: string):
Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByLabel(label, {focused: true, shortcut: true}));
}
/**
* Wait for the tree item with the label to have the the current directory
* aria-description attribute.
*
* @param label Label of the tree item
*/
async waitForCurrentDirectoryItemByLabel(label: string):
Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByLabel(label, {currentDirectory: true}));
}
/**
* Wait for the child items of the specific parent item to match the count.
*
* @param parentLabel Label of the parent tree item.
* @param count Expected number of the child items.
* @param excludeEmptyChild Set true to only return child items with nested
* children.
*/
async waitForChildItemsCountByLabel(
parentLabel: string, count: number,
excludeEmptyChild?: boolean): Promise<void> {
const itemSelector = this.selectors_.itemByLabel(parentLabel);
const childItemsSelector = excludeEmptyChild ?
this.selectors_.nonEmptyChildItems(itemSelector) :
this.selectors_.childItems(itemSelector);
return remoteCall.waitForElementsCount(
this.appId_, [childItemsSelector], count);
}
/**
* Wait for the placeholder tree items specified by type to match the count.
*
* @param type Type of the placeholder tree item.
* @param count Expected number of the child items.
*/
async waitForPlaceholderItemsCountByType(type: string, count: number):
Promise<void> {
const itemSelector =
this.selectors_.itemByType(type, /* isPlaceholder= */ true);
return remoteCall.waitForElementsCount(this.appId_, [itemSelector], count);
}
/** Get the currently focused tree item. */
async getFocusedItem(): Promise<null|ElementObject> {
const focusedItemSelector = this.selectors_.attachModifier(
`${this.selectors_.root} ${this.selectors_.item}`, {focused: true});
const elements = await remoteCall.callRemoteTestUtil<ElementObject[]>(
'deepQueryAllElements', this.appId_, [focusedItemSelector]);
if (elements && elements.length > 0) {
return elements[0]!;
}
return null;
}
/** Gets the label of the tree item. */
getItemLabel(item: ElementObject|null): string {
if (!item) {
chrome.test.fail('Item is not a valid tree item.');
}
return item.attributes['label']!;
}
/** Gets the volume type of the tree item. */
getItemVolumeType(item: ElementObject|null): string {
if (!item) {
chrome.test.fail('Item is not a valid tree item.');
}
return item.attributes['volume-type-for-testing']!;
}
/** Check if the tree item is disabled or not. */
assertItemDisabled(item: ElementObject|null) {
if (!item) {
chrome.test.fail('Item is not a valid tree item.');
}
// Empty value for "disabled" means it's disabled.
chrome.test.assertEq('', item.attributes['disabled']);
}
/**
* Wait for the item with the label to get the `has-children` attribute with
* the specified value.
*
* @param label Label of the tree item.
* @param hasChildren should the tree item have children or not.
*/
async waitForItemToHaveChildrenByLabel(label: string, hasChildren: boolean):
Promise<ElementObject> {
// Expand the item first before checking its children.
if (hasChildren) {
await this.expandTreeItemByLabel(label);
}
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByLabel(label, {hasChildren: hasChildren}));
}
/**
* Wait for the item with the type to get the `has-children` attribute with
* the specified value.
*
* @param type Type of the tree item.
* @param hasChildren should the tree item have children or not.
*/
async waitForItemToHaveChildrenByType(type: string, hasChildren: boolean):
Promise<ElementObject> {
// Expand the item first before checking its children.
if (hasChildren) {
await this.expandTreeItemByType(type);
}
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByType(
type, /* isPlaceholder= */ false, {hasChildren: hasChildren}));
}
/**
* Wait for the item with the label to get the `may-have-children` attribute.
*
* @param label Label of the tree item.
*/
async waitForItemToMayHaveChildrenByLabel(label: string):
Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByLabel(label, {mayHaveChildren: true}));
}
/**
* Wait for the item with the label to be expanded.
*
* @param label Label of the tree item.
*/
async waitForItemToExpandByLabel(label: string): Promise<void> {
const expandedItemSelector =
this.selectors_.itemByLabel(label, {expanded: true});
await remoteCall.waitForElement(this.appId_, expandedItemSelector);
}
/**
* Wait for the item with the label to be collapsed.
*
* @param label Label of the tree item.
*/
async waitForItemToCollapseByLabel(label: string): Promise<void> {
const collapsedItemSelector =
this.selectors_.itemByLabel(label, {expanded: false});
await remoteCall.waitForElement(this.appId_, collapsedItemSelector);
}
/**
* Expands a single tree item with the specified label by clicking on its
* expand icon.
* @param label Label of the tree item we want to expand on.
* @param allowEmpty Allow expanding tree item without any children.
*/
async expandTreeItemByLabel(label: string, allowEmpty?: boolean):
Promise<void> {
await this.expandTreeItem_(this.selectors_.itemByLabel(label), allowEmpty);
}
/**
* Expands a single tree item with the specified type by clicking on its
* expand icon.
* @param type Type of the tree item we want to expand on.
* @param allowEmpty Allow expanding tree item without any children.
*/
async expandTreeItemByType(type: string, allowEmpty?: boolean):
Promise<void> {
await this.expandTreeItem_(this.selectors_.itemByType(type), allowEmpty);
}
/**
* Expands a single tree item with the specified full path by clicking on its
* expand icon.
* @param path Path of the tree item we want to expand on.
*/
async expandTreeItemByPath(path: string): Promise<void> {
await this.expandTreeItem_(this.selectors_.itemByPath(path));
}
/**
* Collapses a single tree item with the specified label by clicking on its
* expand icon.
* @param label Label of the tree item we want to collapse on.
*/
async collapseTreeItemByLabel(label: string): Promise<void> {
await this.collapseTreeItem_(this.selectors_.itemByLabel(label));
}
/**
* Expands each directory in the breadcrumbs path.
*
* @param breadcrumbsPath Path based in the entry labels like:
* /My files/Downloads/photos.
* @return Promise fulfilled on success with the selector query of the last
* directory expanded.
*/
async recursiveExpand(breadcrumbsPath: string): Promise<string> {
const paths = breadcrumbsPath.split('/').filter(path => path);
// Expand each directory in the breadcrumb.
let query = this.selectors_.root;
for (const parentLabel of paths) {
// Wait for parent element to be displayed.
query += ` ${this.selectors_.itemItselfByLabel(parentLabel)}`;
await remoteCall.waitForElement(this.appId_, query);
// Only expand if element isn't expanded yet.
const elements = await remoteCall.callRemoteTestUtil<ElementObject[]>(
'queryAllElements', this.appId_,
[this.selectors_.attachModifier(query, {expanded: true})]);
if (elements.length === 0) {
await this.expandTreeItem_(query);
}
}
return Promise.resolve(query);
}
/**
* Focus the directory tree and navigates using mouse clicks.
*
* @param breadcrumbsPath Path based on the entry labels like:
* /My files/Downloads/photos to item that should navigate to.
* @param shortcutToPath For shortcuts it navigates to a different breadcrumbs
* path, like /My Drive/ShortcutName.
* @return the final selector used to click on the desired tree item.
*/
async navigateToPath(breadcrumbsPath: string, shortcutToPath?: string):
Promise<string> {
// Focus the directory tree.
await this.focusTree();
const paths = breadcrumbsPath.split('/');
// For "/My Drive", expand the "Google Drive" first.
if (paths[1] === 'My Drive') {
paths.unshift('', 'Google Drive');
}
const leaf = paths.pop()!;
// Expand all parents of the leaf entry.
let query = await this.recursiveExpand(paths.join('/'));
// Navigate to the final entry.
query += ` ${this.selectors_.itemItselfByLabel(leaf)}`;
await remoteCall.waitAndClickElement(this.appId_, query);
// Wait directory to finish scanning its content.
await remoteCall.waitForElement(this.appId_, `[scan-completed="${leaf}"]`);
// If the search was not closed, wait for it to close.
await remoteCall.waitForElement(this.appId_, '#search-wrapper[collapsed]');
// Wait to navigation to final entry to finish.
await remoteCall.waitUntilCurrentDirectoryIsChanged(
this.appId_, (shortcutToPath || breadcrumbsPath));
// Focus the directory tree.
await this.focusTree();
return query;
}
/**
* Trigger a keydown event with ArrowUp key to move the focus to the previous
* tree item.
*/
async focusPreviousItem(): Promise<void> {
// Focus the tree first before keyboard event.
await this.focusTree();
const arrowUp =
[this.selectors_.keyboardRecipient, 'ArrowUp', false, false, false];
await remoteCall.callRemoteTestUtil('fakeKeyDown', this.appId_, arrowUp);
}
/**
* Trigger a keydown event with ArrowDown key to move the focus to the next
* tree item.
*
*/
async focusNextItem(): Promise<void> {
// Focus the tree first before keyboard event.
await this.focusTree();
const arrowUp =
[this.selectors_.keyboardRecipient, 'ArrowDown', false, false, false];
await remoteCall.callRemoteTestUtil('fakeKeyDown', this.appId_, arrowUp);
}
/**
* Trigger a keydown event with Enter key to select currently focused item.
*
*/
async selectFocusedItem(): Promise<void> {
// Focus the tree first before keyboard event.
await this.focusTree();
const enter =
[this.selectors_.keyboardRecipient, 'Enter', false, false, false];
await remoteCall.callRemoteTestUtil('fakeKeyDown', this.appId_, enter);
}
/**
* Wait for the tree item by its label.
*
* @param label Label of the tree item.
*/
async waitForItemByLabel(label: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_, this.selectors_.itemByLabel(label));
}
/**
* Wait for the tree item by its label to be lost.
*
* @param label Label of the tree item.
*/
async waitForItemLostByLabel(label: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_, this.selectors_.itemByLabel(label));
}
/**
* Wait for the tree item by its full path.
*
* @param path Path of the tree item.
*/
async waitForItemByPath(path: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_, this.selectors_.itemByPath(path));
}
/**
* Wait for the tree item by its full path to be lost.
*
* @param path Path of the tree item.
*/
async waitForItemLostByPath(path: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_, this.selectors_.itemByPath(path));
}
/** Returns the labels for all visible tree items. */
async getVisibleItemLabels(): Promise<string[]> {
const allItems = await remoteCall.callRemoteTestUtil<ElementObject[]>(
'queryAllElements', this.appId_, [
`${this.selectors_.root} ${this.selectors_.item}`,
['visibility'],
]);
return allItems
.filter(item => !item.hidden && item.styles!['visibility'] !== 'hidden')
.map(item => this.getItemLabel(item));
}
/**
* Wait for the tree item by its type.
*
* @param type Type of the tree item.
*/
async waitForItemByType(type: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByType(type, /* isPlaceholder= */ false));
}
/**
* Wait for the tree item by its type to be lost.
*
* @param type Type of the tree item.
*/
async waitForItemLostByType(type: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_,
this.selectors_.itemByType(type, /* isPlaceholder= */ false));
}
/**
* Wait for the placeholder tree item by its type.
*
* @param type Type of the tree item.
*/
async waitForPlaceholderItemByType(type: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.itemByType(type, /* isPlaceholder= */ true));
}
/**
* Wait for the placeholder tree item by its type to be lost.
*
* @param type Type of the tree item.
*/
async waitForPlaceholderItemLostByType(type: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_,
this.selectors_.itemByType(type, /* isPlaceholder= */ true));
}
/**
* Wait for the shortcut tree item by its label.
*
* @param label Label of the tree item.
*/
async waitForShortcutItemByLabel(label: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_, this.selectors_.itemByLabel(label, {shortcut: true}));
}
/**
* Wait for the shortcut tree item by its label to be lost.
*
* @param label Label of the tree item.
*/
async waitForShortcutItemLostByLabel(label: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_, this.selectors_.itemByLabel(label, {shortcut: true}));
}
/**
* Wait for the child tree item under a specified parent item by their label.
*
* @param parentLabel Label of the parent item.
* @param childLabel Label of the child item.
*/
async waitForChildItemByLabel(parentLabel: string, childLabel: string):
Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.childItem(
this.selectors_.itemByLabel(parentLabel),
this.selectors_.itemItselfByLabel(childLabel)));
}
/**
* Wait for the child tree item to be lost under a specified parent item by
* its label.
*
* @param parentLabel Label of the parent item.
* @param childLabel Label of the child item.
*/
async waitForChildItemLostByLabel(parentLabel: string, childLabel: string):
Promise<void> {
await remoteCall.waitForElementLost(
this.appId_,
this.selectors_.childItem(
this.selectors_.itemByLabel(parentLabel),
this.selectors_.itemItselfByLabel(childLabel)));
}
/**
* Wait for the group root tree item (e.g. entry list) by its type.
*
* @param type Type of the tree item.
*/
async waitForGroupRootItemByType(type: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_, this.selectors_.groupRootItemByType(type));
}
/**
* Returns the child items of a parent item specified by its label.
*
* @param parentLabel Label of the parent item.
*/
async getChildItemsByParentLabel(parentLabel: string):
Promise<ElementObject[]> {
const parentItemSelector = this.selectors_.itemByLabel(parentLabel);
const childItemsSelector = this.selectors_.childItems(parentItemSelector);
return remoteCall.callRemoteTestUtil(
'queryAllElements', this.appId_, [childItemsSelector]);
}
/**
* Wait for the eject button under the tree item by its type.
*
* @param type Type of the tree item.
*/
async waitForItemEjectButtonByType(type: string): Promise<ElementObject> {
return remoteCall.waitForElement(
this.appId_,
this.selectors_.ejectButton(this.selectors_.itemByType(type)));
}
/**
* Wait for the eject button to be lost under the tree item by its type.
*
* @param type Type of the tree item.
*/
async waitForItemEjectButtonLostByType(type: string): Promise<void> {
await remoteCall.waitForElementLost(
this.appId_,
this.selectors_.ejectButton(this.selectors_.itemByType(type)));
}
/**
* Click the eject button under the tree item by its type.
*
* @param type Type of the tree item.
*/
async ejectItemByType(type: string): Promise<ElementObject> {
return remoteCall.waitAndClickElement(
this.appId_,
this.selectors_.ejectButton(this.selectors_.itemByType(type)));
}
/**
* Click the eject button under the tree item by its label.
*
* @param label Label of the tree item.
*/
async ejectItemByLabel(label: string): Promise<ElementObject> {
return remoteCall.waitAndClickElement(
this.appId_,
this.selectors_.ejectButton(this.selectors_.itemByLabel(label)));
}
/**
* Wait for the expand icon under the tree item to show by its label.
*
* @param label Label of the tree item.
*/
async waitForItemExpandIconToShowByLabel(label: string): Promise<void> {
const expandIcon =
this.selectors_.expandIcon(this.selectors_.itemByLabel(label));
const caller = getCaller();
return repeatUntil(async () => {
const element = await remoteCall.waitForElementStyles(
this.appId_,
expandIcon,
['visibility'],
);
if (element.styles!['visibility'] !== 'visible') {
return pending(
caller, `Expand icon for tree item ${label} is still hidden.`);
}
return undefined;
});
}
/**
* Wait for the expand icon under the tree item to hide by its label.
*
* @param label Label of the tree item.
*/
async waitForItemExpandIconToHideByLabel(label: string): Promise<void> {
const expandIcon =
this.selectors_.expandIcon(this.selectors_.itemByLabel(label));
const caller = getCaller();
return repeatUntil(async () => {
const element = await remoteCall.waitForElementStyles(
this.appId_,
expandIcon,
['visibility'],
);
if (element.styles!['visibility'] !== 'hidden') {
return pending(
caller, `Expand icon for tree item ${label} is still showing.`);
}
return undefined;
});
}
/**
* Wait for the tree item specified by label to accept drag/drop.
*
* @param label Label of the tree item.
*/
async waitForItemToAcceptDropByLabel(label: string): Promise<void> {
const itemAcceptDrop =
this.selectors_.itemByLabel(label, {acceptDrop: true});
const itemDenyDrop =
this.selectors_.itemByLabel(label, {acceptDrop: false});
await remoteCall.waitForElement(this.appId_, itemAcceptDrop);
await remoteCall.waitForElementLost(this.appId_, itemDenyDrop);
}
/**
* Wait for the tree item specified by label to deny drag/drop.
*
* @param label Label of the tree item.
*/
async waitForItemToDenyDropByLabel(label: string): Promise<void> {
const itemAcceptDrop =
this.selectors_.itemByLabel(label, {acceptDrop: true});
const itemDenyDrop =
this.selectors_.itemByLabel(label, {acceptDrop: false});
await remoteCall.waitForElement(this.appId_, itemDenyDrop);
await remoteCall.waitForElementLost(this.appId_, itemAcceptDrop);
}
/**
* Drag files specified by `sourceQuery` to the target tree item specified by
* the `targetLabel`.
*
* @param sourceQuery Query to specify the source element.
* @param targetLabel The drop target tree item label.
* @param skipDrop Set true to drag over (hover) the target only, and not send
* target drop or source dragend events.
*/
async dragFilesToItemByLabel(
sourceQuery: string, targetLabel: string, skipDrop: boolean):
Promise<((dragEndQuery: string, dragLeave: boolean) => Promise<void>)> {
const target = this.selectors_.itemByLabel(targetLabel);
chrome.test.assertTrue(
await remoteCall.callRemoteTestUtil(
'fakeDragAndDrop', this.appId_, [sourceQuery, target, skipDrop]),
'fakeDragAndDrop failed');
// A function is being returned to let the caller finish drop if drop
// is skipped above.
if (skipDrop) {
return this.finishDrop_.bind(this, target);
}
return async () => {};
}
/**
* @param targetQuery Query to specify the drop target.
* @param dragEndQuery Query to specify which element to trigger the dragend
* event.
* @param dragLeave Set true to send a dragleave event to the target instead
* of a drop event.
*/
private async finishDrop_(
targetQuery: string, dragEndQuery: string,
dragLeave: boolean): Promise<void> {
chrome.test.assertTrue(
await remoteCall.callRemoteTestUtil(
'fakeDragLeaveOrDrop', this.appId_,
[dragEndQuery, targetQuery, dragLeave]),
'fakeDragLeaveOrDrop failed');
}
/**
* Use keyboard shortcut to trigger rename for a tree item.
*
* @param label Label of the tree item to trigger rename.
*/
async triggerRenameWithKeyboardByLabel(label: string): Promise<void> {
const itemSelector = this.selectors_.itemByLabel(label, {focused: true});
// Press rename <Ctrl>-Enter keyboard shortcut on the tree item.
const renameKey = [
itemSelector,
'Enter',
true,
false,
false,
];
await remoteCall.callRemoteTestUtil('fakeKeyDown', this.appId_, renameKey);
}
/**
* Waits for the rename input to show inside the tree item.
*
* @param label Label of the tree item.
*/
async waitForRenameInputByLabel(label: string): Promise<ElementObject> {
const itemSelector = this.selectors_.itemByLabel(label);
const textInput = this.selectors_.renameInput(itemSelector);
return remoteCall.waitForElement(this.appId_, textInput);
}
/**
* Input the new name to the tree item specified by its label without pressing
* Enter to commit.
*
* @param label Label of the tree item.
* @param newName The new name.
*/
async inputNewNameForItemByLabel(label: string, newName: string):
Promise<void> {
const itemSelector = this.selectors_.itemByLabel(label);
// Check: the renaming text input element should appear.
const textInputSelector = this.selectors_.renameInput(itemSelector);
await remoteCall.waitForElement(this.appId_, textInputSelector);
// Enter the new name for the tree item.
await remoteCall.inputText(this.appId_, textInputSelector, newName);
}
/**
* Renames the tree item specified by the label to the new name.
*
* @param label Label of the tree item.
* @param newName The new name.
*/
async renameItemByLabel(label: string, newName: string): Promise<void> {
const itemSelector = this.selectors_.itemByLabel(label);
const textInputSelector = this.selectors_.renameInput(itemSelector);
await this.inputNewNameForItemByLabel(label, newName);
// Press Enter key to end text input.
const enterKey = [textInputSelector, 'Enter', false, false, false];
await remoteCall.callRemoteTestUtil('fakeKeyDown', this.appId_, enterKey);
// Wait for the renaming input element to disappear.
await remoteCall.waitForElementLost(this.appId_, textInputSelector);
// Wait until renaming is complete.
const renamingItemSelector = this.selectors_.attachModifier(
`${this.selectors_.root} ${this.selectors_.item}`, {renaming: true});
await remoteCall.waitForElementLost(this.appId_, renamingItemSelector);
}
/**
* Wait for the tree item specified by label to finish drag/drop.
*
* @param label Label of the tree item.
*/
async waitForItemToFinishDropByLabel(label: string): Promise<void> {
const itemAcceptDrop =
this.selectors_.itemByLabel(label, {acceptDrop: true});
const itemDenyDrop =
this.selectors_.itemByLabel(label, {acceptDrop: false});
await remoteCall.waitForElementLost(this.appId_, itemDenyDrop);
await remoteCall.waitForElementLost(this.appId_, itemAcceptDrop);
}
/**
* Select the tree item by its label.
*
* @param label Label of the tree item.
*/
async selectItemByLabel(label: string): Promise<void> {
await this.selectItem_(this.selectors_.itemByLabel(label));
}
/**
* Select the tree item by its type.
*
* @param type Type of the tree item.
*/
async selectItemByType(type: string): Promise<void> {
if (this.selectors_.isInsideDrive(type)) {
await this.expandTreeItemByLabel('Google Drive');
}
await this.selectItem_(
this.selectors_.itemByType(type, /* isPlaceholder= */ false));
}
/**
* Select the tree item by its path.
*
* @param path Full path of the tree item.
*/
async selectItemByPath(path: string): Promise<void> {
await this.selectItem_(this.selectors_.itemByPath(path));
}
/**
* Select the group root tree item (e.g. entry list) by its type.
*
* @param type Type of the tree item.
*/
async selectGroupRootItemByType(type: string): Promise<void> {
await this.selectItem_(this.selectors_.groupRootItemByType(type));
}
/**
* Select the placeholder tree item by its type.
*
* @param type Type of the placeholder tree item.
*/
async selectPlaceholderItemByType(type: string): Promise<void> {
await this.selectItem_(
this.selectors_.itemByType(type, /* isPlaceholder= */ true));
}
/**
* Select the shortcut tree item by its label.
*
* @param label Label of the tree item.
*/
async selectShortcutItemByLabel(label: string): Promise<void> {
await this.selectItem_(
this.selectors_.itemByLabel(label, {shortcut: true}));
}
/**
* Show context menu for the tree item by its label.
*
* @param label Label of the tree item.
*/
async showContextMenuForItemByLabel(label: string): Promise<void> {
await this.showItemContextMenu_(this.selectors_.itemByLabel(label));
}
/**
* Long press the tree item by its label to trigger context menu.
*
* @param label Label of the tree item.
*/
async longPressItemByLabel(label: string): Promise<void> {
chrome.test.assertTrue(
!!await remoteCall.callRemoteTestUtil(
'fakeContextMenu', this.appId_,
[this.selectors_.itemByLabel(label)]),
'fakeContextMenu failed');
}
/**
* Show context menu for the tree item by its full path.
*
* @param path Path of the tree item.
*/
async showContextMenuForItemByPath(path: string): Promise<void> {
await this.showItemContextMenu_(this.selectors_.itemByPath(path));
}
/**
* Show context menu for the shortcut item by its label.
*
* @param label Label of the shortcut tree item.
*/
async showContextMenuForShortcutItemByLabel(label: string): Promise<void> {
await this.showItemContextMenu_(
this.selectors_.itemByLabel(label, {shortcut: true}));
}
/**
* Show context menu for the eject button inside the tree item.
*
* @param label Label of the tree item.
*/
async showContextMenuForEjectButtonByLabel(label: string): Promise<void> {
const itemSelector = this.selectors_.itemByLabel(label);
const ejectButton = this.selectors_.ejectButton(itemSelector);
await remoteCall.waitForElement(this.appId_, ejectButton);
// Focus on the eject button.
chrome.test.assertTrue(
!!await remoteCall.callRemoteTestUtil(
'focus', this.appId_, [ejectButton]),
'focus failed: eject button');
// Right click the eject button.
await remoteCall.waitAndRightClick(this.appId_, ejectButton);
}
/**
* Show context menu for the rename input inside the tree item.
*
* @param label Label of the tree item.
*/
async showContextMenuForRenameInputByLabel(label: string): Promise<void> {
const itemSelector = this.selectors_.itemByLabel(label);
const renameInput = this.selectors_.renameInput(itemSelector);
await remoteCall.waitAndRightClick(this.appId_, renameInput);
}
/**
* Focus the tree.
*
*/
async focusTree(): Promise<void> {
await remoteCall.callRemoteTestUtil(
'focus', this.appId_, [this.selectors_.root]);
}
/**
* Send a blur even to the tree item specified by its label.
*
* @param label Label of the tree item.
*/
async blurItemByLabel(label: string): Promise<void> {
const itemSelector = this.selectors_.itemByLabel(label);
const iconSelector = [
itemSelector,
'.tree-item > .tree-row-wrapper > .tree-row > .tree-label-icon',
];
await remoteCall.callRemoteTestUtil(
'fakeEvent', this.appId_, [iconSelector, 'blur']);
}
/** Show the context menu for a tree item by right clicking it. */
private async showItemContextMenu_(itemSelector: string): Promise<void> {
await remoteCall.waitAndRightClick(this.appId_, itemSelector);
}
/** Select the tree item by clicking it. */
private async selectItem_(itemSelector: string): Promise<void> {
await remoteCall.waitAndClickElement(this.appId_, [itemSelector]);
}
/**
* Expands a single tree item by clicking on its expand icon.
*
* @param itemSelector Selector to the tree item that should be expanded.
* @param allowEmpty Allow expanding tree item without any children.
*/
private async expandTreeItem_(itemSelector: string, allowEmpty?: boolean):
Promise<void> {
await remoteCall.waitForElement(this.appId_, itemSelector);
const elements = await remoteCall.callRemoteTestUtil<ElementObject[]>(
'queryAllElements', this.appId_,
[this.selectors_.attachModifier(itemSelector, {expanded: true})]);
// If it's already expanded just set the focus on directory tree.
if (elements.length > 0) {
return;
}
// Use array here because they are inside shadow DOM.
const expandIcon = [
this.selectors_.attachModifier(itemSelector, {expanded: false}),
'.tree-item > .tree-row-wrapper > .tree-row > .expand-icon',
];
const expandedSubtree = [
this.selectors_.attachModifier(itemSelector, {expanded: true}),
'.tree-item[aria-expanded="true"]',
];
await remoteCall.waitAndClickElement(this.appId_, expandIcon);
if (!allowEmpty) {
// Wait for the expansion to finish.
await remoteCall.waitForElement(this.appId_, expandedSubtree);
}
}
/**
* Collapses a single tree item by clicking on its expand icon.
*
* @param itemSelector Selector to the tree item that should be expanded.
*/
private async collapseTreeItem_(itemSelector: string): Promise<void> {
await remoteCall.waitForElement(this.appId_, itemSelector);
const elements = await remoteCall.callRemoteTestUtil<ElementObject[]>(
'queryAllElements', this.appId_,
[this.selectors_.attachModifier(itemSelector, {expanded: false})]);
// If it's already collapsed just set the focus on directory tree.
if (elements.length > 0) {
return;
}
// Use array here because they are inside shadow DOM.
const expandIcon = [
this.selectors_.attachModifier(itemSelector, {expanded: true}),
'.tree-item > .tree-row-wrapper > .tree-row > .expand-icon',
];
await remoteCall.waitAndClickElement(this.appId_, expandIcon);
await remoteCall.waitForElement(
this.appId_,
this.selectors_.attachModifier(itemSelector, {expanded: false}));
}
}
/**
* Selectors of DirectoryTree, all the method provided by this class return the
* selector string.
*/
class DirectoryTreeSelectors {
/** The root selector of the directory tree. */
get root(): string {
return '#directory-tree';
}
/** The container selector of the directory tree. */
get container(): string {
return '.dialog-navigation-list';
}
/** The tree item selector. */
get item(): string {
return 'xf-tree-item';
}
/** Get tree item by the label of the item. */
itemByLabel(label: string, modifiers?: ModifierOptions): string {
const itemSelector = `${this.root} ${this.itemItselfByLabel(label)}`;
return this.attachModifier(itemSelector, modifiers);
}
/** Get tree item by the full path of the item. */
itemByPath(path: string, modifiers?: ModifierOptions): string {
const itemSelector = `${this.root} ${this.itemItselfByPath(path)}`;
return this.attachModifier(itemSelector, modifiers);
}
/** Get tree item by the type of the item. */
itemByType(
type: string, isPlaceholder?: boolean,
modifiers?: ModifierOptions): string {
const itemSelector =
`${this.root} ${this.itemItselfByType(type, !!isPlaceholder)}`;
return this.attachModifier(itemSelector, modifiers);
}
/** Get the group root tree item (e.g. entry list) by the type of the item. */
groupRootItemByType(type: string, modifiers?: ModifierOptions): string {
const itemSelector = `${this.root} ${this.groupRootItemItselfByType(type)}`;
return this.attachModifier(itemSelector, modifiers);
}
/** Get all expanded tree items. */
expandedItems(): string {
return `${this.root} ${this.attachModifier(this.item, {expanded: true})}`;
}
/** Get all the direct child items of the specific item. */
childItems(parentSelector: string): string {
return `${parentSelector} > ${this.item}`;
}
/**
* Get the direct child item under a specific parent item.
*
* @param parentSelector The parent item selector.
* @param childSelector The child item selector.
*/
childItem(parentSelector: string, childSelector: string): string {
return `${parentSelector} ${childSelector}`;
}
/**
* Get all direct child items of the specific item, which are not empty (have
* nested children inside).
*/
nonEmptyChildItems(itemSelector: string): string {
// For new tree implementation, `hasChildren` will only be true when there's
// actual tree item rendered inside, hence the use of `mayHaveChildren`
// here instead of `hasChildren`.
return this.attachModifier(
`${itemSelector} > ${this.item}`, {mayHaveChildren: true});
}
/** Get the eject button of the specific tree item. */
ejectButton(itemSelector: string): string {
return `${itemSelector} .root-eject`;
}
/** Get the expand icon of the specific tree item. */
expandIcon(itemSelector: string): string|string[] {
// Use array here because they are inside shadow DOM.
return [itemSelector, '.expand-icon'];
}
/** Get the rename input of the specific tree item. */
renameInput(itemSelector: string): string {
return `${itemSelector} > input`;
}
/**
* Get the tree item itself (without the parent tree selector) by its type.
*
* @param isPlaceholder Is the tree item a placeholder or not.
*/
itemItselfByType(type: string, isPlaceholder: boolean): string {
// volume type for "My files" is "downloads", but in the code when we
// query item by "downloads" type, what we want is the actual Downloads
// folder, hence the special handling here.
if (type === 'downloads') {
return `${this.item}[data-navigation-key^="${
REAL_ENTRY_PATH_PREFIX}"][icon="downloads"]`;
}
return isPlaceholder ?
`${this.item}[data-navigation-key^="${FAKE_ENTRY_PATH_PREFIX}"][icon="${
type}"]` :
`${this.item}[data-navigation-key^="${
REAL_ENTRY_PATH_PREFIX}"][volume-type-for-testing="${type}"]`;
}
/**
* Get the group root tree item (e.g. entry list) itself (without the parent
* tree selector) by its type.
*/
groupRootItemItselfByType(type: string): string {
// For EntryList, there are some differences between the old/new tree on the
// icon names. Format: <old-tree-icon-name>: <new-tree-icon-name>.
const iconNameMap: Record<string, string> = {
'drive': 'service_drive',
'removable': 'usb',
};
if (type in iconNameMap) {
type = iconNameMap[type]!;
}
return `${this.item}[data-navigation-key^="${
ENTRY_LIST_PATH_PREFIX}"][icon="${type}"]`;
}
/**
* Get the tree item itself (without the parent tree selector) by its label.
*
* @param label The label of the tree item.
*/
itemItselfByLabel(label: string): string {
return `${this.item}[label="${label}"]`;
}
/**
* Get the tree item itself (without the parent tree selector) by its path.
*
* @param path The full path of the tree item.
*/
itemItselfByPath(path: string): string {
return `${this.item}[full-path-for-testing="${path}"]`;
}
/**
* Check if the volume type is inside the Google Drive volume or not.
*
* @param type The volume type of the tree item.
*/
isInsideDrive(type: string): boolean {
return type === 'drive_recent' || type === 'drive_shared_with_me' ||
type === 'drive_offline' || type === 'shared_drive' ||
type === 'computer';
}
/** Return the recipient element of the keyboard event. */
get keyboardRecipient() {
return this.root;
}
/** Append the modifier selector to the item selector. */
attachModifier(itemSelector: string, modifiers: ModifierOptions = {}) {
const appendedSelectors: string[] = [];
if (typeof modifiers.expanded !== 'undefined') {
appendedSelectors.push(
modifiers.expanded ? '[expanded]' : ':not([expanded])');
}
if (modifiers.selected) {
appendedSelectors.push('[selected]');
}
if (modifiers.renaming) {
appendedSelectors.push('[renaming]');
}
if (typeof modifiers.acceptDrop !== 'undefined') {
appendedSelectors.push(modifiers.acceptDrop ? '.accepts' : '.denies');
}
if (typeof modifiers.hasChildren !== 'undefined') {
appendedSelectors.push(
`[has-children="${String(modifiers.hasChildren)}"]`);
}
if (modifiers.mayHaveChildren) {
appendedSelectors.push('[may-have-children]');
}
if (modifiers.currentDirectory) {
appendedSelectors.push('[aria-description="Current directory"]');
}
if (modifiers.shortcut) {
appendedSelectors.push('[icon="shortcut"]');
}
// ":focus" is a pseudo-class selector, should be put at the end.
if (modifiers.focused) {
appendedSelectors.push(':focus');
}
return `${itemSelector}${appendedSelectors.join('')}`;
}
}