From 64b2dcc1303153e21a16f2e5522967084538f5c8 Mon Sep 17 00:00:00 2001 From: Dominik Jelinek Date: Thu, 11 Apr 2024 20:04:39 +0200 Subject: [PATCH] feat: Add support for array string settings Signed-off-by: Dominik Jelinek --- docs/Setting.md | 18 +- docs/SettingsEditor.md | 10 +- packages/locators/lib/1.37.0.ts | 12 +- .../src/components/editor/SettingsEditor.ts | 201 +++++++++++++++++- .../page-objects/src/locators/locators.ts | 10 + tests/test-project/package.json | 15 +- .../src/test/editor/settingsEditor.test.ts | 118 +++++++++- 7 files changed, 367 insertions(+), 17 deletions(-) diff --git a/docs/Setting.md b/docs/Setting.md index 474b838e6..ac71e4b50 100644 --- a/docs/Setting.md +++ b/docs/Setting.md @@ -1,7 +1,9 @@ ![setting](https://user-images.githubusercontent.com/4181232/62535346-76668900-b84b-11e9-8aa3-a07f25e1e37e.png) #### Lookup + Settings can be located through a [[SettingsEditor]] object: + ```typescript import { Workbench } from 'vscode-extension-tester'; ... @@ -13,6 +15,7 @@ const setting = await settingsEditor.findSetting('Auto Save', 'Files'); ``` #### Retrieve Information + ```typescript // get the title const title = setting.getTitle(); @@ -25,7 +28,9 @@ const decription = await setting.getDescription(); ``` #### Handling Values + All setting types share the same functions to manipulate their values, however the value types and possible options vary between setting types. + ```typescript // generic value retrieval const value = await setting.getValue(); @@ -35,8 +40,11 @@ await setting.setValue('off'); ``` ##### Setting Value Types -Currently, there are four supported types of setting values: text box, combo box, checkbox and link. - - Text box allows putting in an arbitrary string value, though there might be value checks afterwards that are not handled by this class. - - Combo box only allows inputs from its range of options. If you cast the setting to `ComboSetting`, you will be able to retrieve these options by calling the `getValues` method. - - Checkbox only accepts boolean values, other values are ignored - - Link does not have any value, `getValue` and `setValue` throw an error. Instead, casting the object to `LinkSetting` will allow you to call the `openLink` method, which will open settings.json file in a text editor. \ No newline at end of file + +Currently, there are four supported types of setting values: **text box**, **combo box**, **checkbox**, **link** and **array of strings**. + +- **Text box** allows putting in an arbitrary string value, though there might be value checks afterwards that are not handled by this class. +- **Combo box** only allows inputs from its range of options. If you cast the setting to `ComboSetting`, you will be able to retrieve these options by calling the `getValues` method. +- **Check box** only accepts boolean values, other values are ignored +- **Link** does not have any value, `getValue` and `setValue` throw an error. Instead, casting the object to `LinkSetting` will allow you to call the `openLink` method, which will open settings.json file in a text editor. +- **Array** settings are supported for type `string`. Each row of array is represented by `ArraySettingItem`. diff --git a/docs/SettingsEditor.md b/docs/SettingsEditor.md index 1d44e78d3..647645232 100644 --- a/docs/SettingsEditor.md +++ b/docs/SettingsEditor.md @@ -1,7 +1,9 @@ ![settings](https://user-images.githubusercontent.com/4181232/62535349-78304c80-b84b-11e9-80ae-25b587f11354.png) #### Lookup + Settings editor can be opened through variety of ways, recommended way is using the [[Workbench]] class: + ```typescript import { Workbench, SettingsEditor } from 'vscode-extension-tester' ... @@ -9,7 +11,9 @@ const settingsEditor = await new Workbench().openSettings(); ``` #### Find a Setting Item in the Editor -Search for a setting with a given name and category, see more about the [[Setting]] object: + +Search for a setting with a given name and category, see more about the [[Setting]] object: + ```typescript // look for a setting named 'Auto Save' under 'Editor' category const setting = await settingsEditor.findSetting('Auto Save', 'Editor'); @@ -19,8 +23,10 @@ const setting1 = await settingsEditor.findSetting('Enable', 'Files', 'Simple Dia ``` #### Switch Settings Perspectives + VSCode has two perspectives for its settings: 'User' and 'Workspace'. If your VSCode instance loads from both user and workspace settings.json files, you will be able to switch the perspectives in the editor: + ```typescript // switch to Workspace perspective await settingsEditor.switchToPerspective('Workspace'); -``` \ No newline at end of file +``` diff --git a/packages/locators/lib/1.37.0.ts b/packages/locators/lib/1.37.0.ts index 579f2a797..8435efc51 100644 --- a/packages/locators/lib/1.37.0.ts +++ b/packages/locators/lib/1.37.0.ts @@ -170,7 +170,17 @@ const editor = { checkboxSetting: By.className('setting-value-checkbox'), checkboxChecked: 'aria-checked', linkButton: By.className('edit-in-settings-button'), - itemCount: By.className('settings-count-widget') + itemCount: By.className('settings-count-widget'), + arraySetting: By.className('setting-item-control'), + arrayRoot: By.xpath(`.//div[@role='list' and contains(@class, 'setting-list-widget')]`), + arrayRow: By.className('setting-list-row'), + arrayRowValue: By.className('setting-list-value'), + arrayNewRow: By.className('setting-list-new-row'), + arrayEditRow: By.className('setting-list-edit-row'), + arrayBtnConstructor: (label: string) => By.xpath(`.//a[contains(@role, 'button') and @aria-label='${label}']`), + arraySettingItem: { + btnConstructor: (label: string) => By.xpath(`.//a[contains(@role, 'button') and text()='${label}']`) + } }, DiffEditor: { originalEditor: By.className('original-in-monaco-diff-editor'), diff --git a/packages/page-objects/src/components/editor/SettingsEditor.ts b/packages/page-objects/src/components/editor/SettingsEditor.ts index 136eab219..a276f5ab5 100644 --- a/packages/page-objects/src/components/editor/SettingsEditor.ts +++ b/packages/page-objects/src/components/editor/SettingsEditor.ts @@ -107,7 +107,7 @@ export class SettingsEditor extends Editor { * Context menu is disabled in this editor, throw an error */ async openContextMenu(): Promise { - throw new Error('Operation not supported'); + throw new Error('Operation not supported!'); } private async createSetting(element: WebElement, title: string, category: string): Promise { @@ -132,7 +132,13 @@ export class SettingsEditor extends Editor { await element.findElement(SettingsEditor.locators.SettingsEditor.linkButton); return new LinkSetting(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category), this); } catch (err) { - throw new Error('Setting type not supported'); + // try array setting + try { + await element.findElement(SettingsEditor.locators.SettingsEditor.arraySetting); + return new ArraySetting(SettingsEditor.locators.SettingsEditor.settingConstructor(title, category), this); + } catch (err) { + throw new Error('Setting type not supported!'); + } } } } @@ -142,7 +148,7 @@ export class SettingsEditor extends Editor { /** * Abstract item representing a Setting with title, description and - * an input element (combo/textbox/checkbox/link) + * an input element (combo/textbox/checkbox/link/array) */ export abstract class Setting extends AbstractElement { @@ -274,11 +280,11 @@ export class CheckboxSetting extends Setting { export class LinkSetting extends Setting { async getValue(): Promise { - throw new Error('Method getValue is not available for LinkSetting'); + throw new Error('Method getValue is not available for LinkSetting!'); } async setValue(value: string | boolean): Promise { - throw new Error('Method setValue is not available for LinkSetting'); + throw new Error('Method setValue is not available for LinkSetting!'); } /** @@ -290,3 +296,188 @@ export class LinkSetting extends Setting { await link.click(); } } + +/** + * TODO + */ +export class ArraySetting extends Setting { + + /** + * @deprecated Method 'getValue' is not available for ArraySetting! + */ + async getValue(): Promise { + throw new Error('Method \'getValue\' is not available for ArraySetting!'); + } + + /** + * @deprecated Method 'setValue' is not available for ArraySetting! + */ + async setValue(value: string | boolean): Promise { + throw new Error('Method \'setValue\' is not available for ArraySetting!'); + } + + /** + * TODO + */ + async select(item: string | number): Promise { + const toSelect = await this.getItem(item); + await toSelect?.select() + } + + /** + * TODO + */ + async getItem(item: string | number): Promise { + const row = await this.findRow(item); + if(row) { + return new ArraySettingItem(row, this); + } + return undefined; + } + + /** + * TODO + */ + async getItems(): Promise { + const listRows = await this.getRows(); + let items: ArraySettingItem[] = []; + for(const row of listRows) { + items.push(new ArraySettingItem(row, this)); + } + return items; + } + + /** + * TODO + */ + async getValues(): Promise { + const items = await this.getItems(); + let values: string[] = []; + for(const item of items) { + values.push(await item.getValue()); + } + return values; + } + + /** + * TODO + */ + async add(): Promise { + // click 'Add Item' button + const button = await this.findElement(SettingsEditor.locators.SettingsEditor.arrayNewRow).findElement(By.className('monaco-button')); + await button.click(); + + // get item row switched to 'edit' mode + const list = await this.getListRootElement(); + const editRow = await list.findElement(SettingsEditor.locators.SettingsEditor.arrayEditRow); + return new ArraySettingItem(editRow, this); + } + + /** + * TODO + */ + async edit(item: string | number): Promise { + const row = await this.findRow(item); + if(row) { + // select item row + const toEdit = new ArraySettingItem(row, this); + await toEdit.select(); + + // click 'Edit Item' button + const edit = await toEdit.findElement(SettingsEditor.locators.SettingsEditor.arrayBtnConstructor('Edit Item')); + await edit.click(); + + // get item row switched to 'edit' mode + const list = await this.getListRootElement(); + const editRow = await list.findElement(SettingsEditor.locators.SettingsEditor.arrayEditRow); + return new ArraySettingItem(editRow, this); + } + return undefined; + } + + private async getListRootElement(): Promise { + return await this.findElement(SettingsEditor.locators.SettingsEditor.arrayRoot); + } + + private async getRows(): Promise { + const list = await this.getListRootElement(); + return await list.findElements(SettingsEditor.locators.SettingsEditor.arrayRow); + } + + private async findRow(item: string | number): Promise { + const listRows = await this.getRows(); + if(Number.isInteger(item)) { + const index = +item; + if (index < 0 || index > listRows.length - 1) { + throw Error(`Index '${index}' is of bounds! Found items have length = ${listRows.length}.`); + } + return listRows[index]; + } else { + for(const row of listRows) { + const li = await row.findElement(SettingsEditor.locators.SettingsEditor.arrayRowValue); + if(await li.getText() === item) { + return row; + } + } + } + return undefined; + } +} + +/** + * TODO + */ +export class ArraySettingItem extends AbstractElement { + + constructor(element: WebElement, setting: ArraySetting) { + super(element, setting); + } + + /** + * TODO + */ + async select(): Promise { + await this.click(); + } + + /** + * TODO + */ + async getValue(): Promise { + return await this.getText(); + } + + /** + * TODO + */ + async setValue(value: string): Promise { + const input = await this.findElement(SettingsEditor.locators.SettingsEditor.textSetting); + await input.clear(); + await input.sendKeys(value); + } + + /** + * TODO + */ + async remove(): Promise { + await this.select(); + const remove = await this.findElement(SettingsEditor.locators.SettingsEditor.arrayBtnConstructor('Remove Item')); + await remove.click(); + } + + /** + * TODO + */ + async ok(): Promise { + const ok = await this.findElement(SettingsEditor.locators.SettingsEditor.arraySettingItem.btnConstructor('OK')); + await ok.click(); + } + + /** + * TODO + */ + async cancel(): Promise { + const cancel = await this.findElement(SettingsEditor.locators.SettingsEditor.arraySettingItem.btnConstructor('Cancel')); + await cancel.click(); + } +} diff --git a/packages/page-objects/src/locators/locators.ts b/packages/page-objects/src/locators/locators.ts index 0660d1d76..0607d888f 100644 --- a/packages/page-objects/src/locators/locators.ts +++ b/packages/page-objects/src/locators/locators.ts @@ -176,6 +176,16 @@ export interface Locators { checkboxChecked: string linkButton: By itemCount: By + arraySetting: By + arrayRoot: By + arrayRow: By + arrayRowValue: By + arrayNewRow: By + arrayEditRow: By + arrayBtnConstructor: (label: string) => By + arraySettingItem: { + btnConstructor: (label: string) => By + } } DiffEditor: { originalEditor: By diff --git a/tests/test-project/package.json b/tests/test-project/package.json index 69eb77fbd..7eb3d3fe3 100644 --- a/tests/test-project/package.json +++ b/tests/test-project/package.json @@ -141,7 +141,7 @@ } ], "configuration": { - "title": "Test Project", + "title": "ExTester Tests", "properties": { "testProject.general.helloWorld": { "type": "boolean", @@ -151,6 +151,19 @@ "testProject.enableCodeLens": { "type": "boolean", "default": false + }, + "testProject.general.helloWorldArray": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "additionalProperties": false, + "markdownDescription": "This is an example array of strings", + "default": [ + "Hello World", + "Hello ExTester" + ] } } }, diff --git a/tests/test-project/src/test/editor/settingsEditor.test.ts b/tests/test-project/src/test/editor/settingsEditor.test.ts index 8b05df6d7..70490fdf3 100644 --- a/tests/test-project/src/test/editor/settingsEditor.test.ts +++ b/tests/test-project/src/test/editor/settingsEditor.test.ts @@ -1,12 +1,13 @@ import { expect } from 'chai'; -import { SettingsEditor, Workbench, EditorView, ComboSetting, TextSetting, CheckboxSetting } from 'vscode-extension-tester'; +import { SettingsEditor, Workbench, EditorView, ComboSetting, TextSetting, CheckboxSetting, ArraySetting } from 'vscode-extension-tester'; -describe('Settings Editor', function () { +describe.only('Settings Editor', function () { let editor: SettingsEditor; before(async function () { - this.timeout(10000); + this.timeout(30000); editor = await new Workbench().openSettings(); + await new Promise(t => setTimeout(t, 5_000)); // wait to be sure settings editor is loaded properly }); after(async function () { @@ -116,4 +117,115 @@ describe('Settings Editor', function () { await setting.setValue(true); }); }); + + describe('array setting', function () { + let setting: ArraySetting; + + before(async function () { + this.timeout(15000); + setting = await editor.findSetting('Hello World Array', 'Test Project', 'General') as ArraySetting; + }); + + it('getItem works - using index', async function () { + const item = await setting.getItem(1); + expect(item).is.not.undefined; + const value = await item.getValue(); + expect(value).is.equal('Hello ExTester'); + }); + + it('getItem works - using label', async function () { + const item = await setting.getItem('Hello World'); + expect(item).is.not.undefined; + const value = await item.getValue(); + expect(value).is.equal('Hello World'); + }); + + it('getItems works', async function () { + const items = await setting.getItems(); + expect(items).is.not.empty; + expect(items.length).is.equal(2); + }); + + it('getValues works', async function () { + const values = await setting.getValues(); + expect(values).contains.members(['Hello World', 'Hello ExTester']); + }); + + it('addItem works', async function () { + const add1 = await setting.add(); + await add1.setValue('Add Item 1'); + await add1.ok(); + await waitUntilItemExists('Add Item 1'); + + const add2 = await setting.add(); + await add2.setValue('Add Item 2'); + await add2.ok(); + await waitUntilItemExists('Add Item 2'); + + const add3 = await setting.add(); + await add3.setValue('Add Item 3'); + await add3.ok(); + await waitUntilItemExists('Add Item 3'); + + const newValue = await setting.getItem('Add Item 1'); + expect(await newValue.getValue()).is.equal('Add Item 1'); + }); + + it('removeItem works - using label', async function () { + const toRemove = await setting.getItem('Hello ExTester'); + await toRemove.remove(); + await waitUntilItemNotExists('Hello ExTester'); + + const values = await setting.getValues(); + expect(values.length).is.lessThan(5); + expect(values).not.includes('Hello ExTester'); + }); + + it('removeItem works - using index', async function () { + const toRemove = await setting.getItem(1); + await toRemove.remove(); + await waitUntilItemNotExists('Add Item 1'); + + const values = await setting.getValues(); + expect(values.length).is.lessThan(4); + expect(values).not.includes('Add Item 1'); + }); + + it('editItem works - using label', async function () { + const toEdit = await setting.edit('Hello World'); + await toEdit.setValue('Edit Item Label'); + await toEdit.ok(); + await waitUntilItemExists('Edit Item Label'); + + const values = await setting.getValues(); + expect(values).includes('Edit Item Label'); + }); + + it('editItem works - using index', async function () { + const toEdit = await setting.edit(1); + await toEdit.setValue('Edit Item Index'); + await toEdit.ok(); + await waitUntilItemExists('Edit Item Index'); + + const values = await setting.getValues(); + expect(values).includes('Edit Item Index'); + }); + + async function waitUntilItemExists(item: string, timeout: number = 10_000): Promise { + let values = []; + await setting.getDriver().wait(async function () { + values = await setting.getValues(); + return values.includes(item); + }, timeout, `Expected item - '${item}' was not found in list of: ${values}`); + } + + async function waitUntilItemNotExists(item: string, timeout: number = 10_000): Promise { + let values = []; + await setting.getDriver().wait(async function () { + values = await setting.getValues(); + return !values.includes(item); + }, timeout, `Expected item - '${item}' was found in list of: ${values}`); + } + }); + });