ljmotta commented on code in PR #2188: URL: https://github.com/apache/incubator-kie-tools/pull/2188#discussion_r1542140216
########## packages/scesim-editor/tests/e2e/__fixtures__/backgroundTable.ts: ########## @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ProjectName } from "@kie-tools/playwright-base/projectNames"; +import { Page } from "@playwright/test"; + +export class BackgroundTable { + constructor(public page: Page, public projectName: ProjectName) {} + + public get() { + return this.page.getByLabel("Background"); + } + + public async fill(args: { content: string; column: number }) { + await this.page.getByLabel("Background").getByTestId("monaco-container").nth(args.column).dblclick(); + if (this.projectName === ProjectName.GOOGLE_CHROME) { + // Google chromes fill function is not always erasing the input content Review Comment: ```suggestion // Google Chrome fill function is not always erasing the input content ``` ########## packages/scesim-editor/tests/e2e/__fixtures__/contextMenu.ts: ########## @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from "@playwright/test"; + +export class ContextMenu { + constructor(public page: Page) {} + + public async openOnCell(args: { rowNumber: string; columnNumber: number }) { + await this.page + .getByRole("row", { name: args.rowNumber }) + .getByTestId("monaco-container") + .nth(args.columnNumber) + .click({ button: "right" }); + } + + public async openOnInstance(args: { name: string }) { + await this.page.getByRole("columnheader", { name: args.name }).click({ button: "right" }); + } + public async openOnProperty(args: { name: string; columnNumber: number }) { + await this.page.getByRole("columnheader", { name: args.name }).nth(args.columnNumber).click({ button: "right" }); + } + public async command(args: { command: string }) { + await this.page.getByRole("menuitem", { name: args.command }).click(); + } + + public async select(args: { rowNumber: string; columnNumber: number; command: string }) { + await this.page + .getByRole("row", { name: args.rowNumber }) + .getByTestId("monaco-container") + .nth(args.columnNumber) + .click({ button: "right" }); + await this.page.getByRole("menuitem", { name: args.command }).click(); + } + + public getHeader(args: { header: string }) { Review Comment: I don't think this is on the right place. The context menu shouldn't have header specific locators. ########## packages/scesim-editor/tests/e2e/__fixtures__/contextMenu.ts: ########## @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from "@playwright/test"; + +export class ContextMenu { + constructor(public page: Page) {} + + public async openOnCell(args: { rowNumber: string; columnNumber: number }) { + await this.page + .getByRole("row", { name: args.rowNumber }) + .getByTestId("monaco-container") + .nth(args.columnNumber) + .click({ button: "right" }); + } + + public async openOnInstance(args: { name: string }) { + await this.page.getByRole("columnheader", { name: args.name }).click({ button: "right" }); + } + public async openOnProperty(args: { name: string; columnNumber: number }) { + await this.page.getByRole("columnheader", { name: args.name }).nth(args.columnNumber).click({ button: "right" }); + } + public async command(args: { command: string }) { + await this.page.getByRole("menuitem", { name: args.command }).click(); + } + + public async select(args: { rowNumber: string; columnNumber: number; command: string }) { Review Comment: Same as above. ########## packages/scesim-editor/tests/e2e/__fixtures__/contextMenu.ts: ########## @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from "@playwright/test"; + +export class ContextMenu { + constructor(public page: Page) {} + + public async openOnCell(args: { rowNumber: string; columnNumber: number }) { + await this.page + .getByRole("row", { name: args.rowNumber }) + .getByTestId("monaco-container") + .nth(args.columnNumber) + .click({ button: "right" }); + } + + public async openOnInstance(args: { name: string }) { + await this.page.getByRole("columnheader", { name: args.name }).click({ button: "right" }); + } + public async openOnProperty(args: { name: string; columnNumber: number }) { + await this.page.getByRole("columnheader", { name: args.name }).nth(args.columnNumber).click({ button: "right" }); + } + public async command(args: { command: string }) { Review Comment: The `command` could be an enumeration? ########## packages/scesim-editor/tests/e2e/scesimEditor/testScenarioTable/contextMenu.spec.ts: ########## @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect } from "../../__fixtures__/base"; +import { AssetType } from "../../__fixtures__/editor"; +import { AddColumnPosition, AddRowPosition } from "../../__fixtures__/table"; + +test.describe("Test scenario table context menu", () => { + test.describe("Context menu checks", () => { + test.beforeEach(async ({ editor, testScenarioTable, table }) => { + await editor.createTestScenario(AssetType.RULE); + await table.addRow({ targetCell: "1", position: AddRowPosition.ABOVE }); + await testScenarioTable.fill({ content: "test", rowLocatorInfo: "1", column: 1 }); + }); + + test("should render select context menu", async ({ contextMenu }) => { + await contextMenu.openOnCell({ rowNumber: "1", columnNumber: 1 }); + await expect(contextMenu.getHeader({ header: "SELECTION" })).toBeAttached(); + await expect(contextMenu.getHeader({ header: "SCENARIO" })).toBeAttached(); + await expect(contextMenu.getHeader({ header: "FIELD" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "INSTANCE" })).not.toBeAttached(); + }); + + test("should render field context menu", async ({ contextMenu }) => { + await contextMenu.openOnProperty({ name: "PROPERTY (<Undefined>)", columnNumber: 1 }); + await expect(contextMenu.getHeader({ header: "SELECTION" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "SCENARIO" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "FIELD" })).toBeAttached(); + await expect(contextMenu.getHeader({ header: "INSTANCE" })).not.toBeAttached(); + }); + + test("should render instance context menu", async ({ contextMenu }) => { + await contextMenu.openOnInstance({ name: "INSTANCE-1 (<Undefined>)" }); + await expect(contextMenu.getHeader({ header: "SELECTION" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "SCENARIO" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "FIELD" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "INSTANCE" })).toBeAttached(); + }); + + test("should add and delete instance column left", async ({ contextMenu, table, testScenarioTable }) => { + await expect(table.getColumnHeader({ name: "INSTANCE-3 (<Undefined>)" })).not.toBeAttached(); Review Comment: Same as previous. I guess we can assume the Given table is without the column ########## packages/scesim-editor/tests/e2e/__fixtures__/testScenarioTable.ts: ########## @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ProjectName } from "@kie-tools/playwright-base/projectNames"; +import { Page } from "@playwright/test"; + +export class TestScenarioTable { + constructor(public page: Page, public projectName: ProjectName) {} + + public get() { + return this.page.getByLabel("Test Scenario"); + } + + public async fill(args: { content: string; rowLocatorInfo: string; column: number }) { + await this.page + .getByRole("row", { name: args.rowLocatorInfo, exact: true }) + .getByTestId("monaco-container") + .nth(args.column) + .dblclick(); + if (this.projectName === ProjectName.GOOGLE_CHROME) { + // Google chromes fill function is not always erasing the input content Review Comment: ```suggestion // Google Chrome fill function is not always erasing the input content ``` ########## packages/scesim-editor/tests/e2e/__fixtures__/editor.ts: ########## @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from "@playwright/test"; +import { SelectorPanel } from "./selectorPanel"; + +export enum AssetType { + DECISION, + RULE, +} + +export class Editor { + constructor(public page: Page, public selectorPanel: SelectorPanel, public baseURL?: string) { + this.page = page; + this.baseURL = baseURL; + } + + public getIframeURL(iframeId: string) { + return `iframe.html?id=${iframeId}&viewMode=story`; + } + + public async openEmpty() { + await this.page.goto(`${this.baseURL}/${this.getIframeURL(`misc-empty--empty`)}` ?? ""); + } + + public async createTestScenario(type: AssetType, background?: boolean) { + await this.openEmpty(); + type === AssetType.DECISION + ? await this.page.locator("#asset-type-select").selectOption("DMN") + : await this.page.locator("#asset-type-select").selectOption("RULE"); + await this.page.getByRole("button", { name: "Create" }).click(); + await this.selectorPanel.close(); + if (background) { + await this.switchToBackgroundTable(); + } Review Comment: As @jomarko pointed out, I guess you can remove this, and make a direct call to `switchToBackgroundTable` on the tests. ########## packages/scesim-editor/tests/e2e/__fixtures__/editor.ts: ########## @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from "@playwright/test"; +import { SelectorPanel } from "./selectorPanel"; + +export enum AssetType { + DECISION, + RULE, +} + +export class Editor { + constructor(public page: Page, public selectorPanel: SelectorPanel, public baseURL?: string) { + this.page = page; + this.baseURL = baseURL; + } + + public getIframeURL(iframeId: string) { + return `iframe.html?id=${iframeId}&viewMode=story`; + } + + public async openEmpty() { + await this.page.goto(`${this.baseURL}/${this.getIframeURL(`misc-empty--empty`)}` ?? ""); + } + + public async createTestScenario(type: AssetType, background?: boolean) { + await this.openEmpty(); + type === AssetType.DECISION + ? await this.page.locator("#asset-type-select").selectOption("DMN") + : await this.page.locator("#asset-type-select").selectOption("RULE"); + await this.page.getByRole("button", { name: "Create" }).click(); + await this.selectorPanel.close(); + if (background) { + await this.switchToBackgroundTable(); + } + } + + public async switchToTestScenarioTable() { + await this.page.getByRole("tab", { name: "Test Scenario" }).click(); + } + public async switchToBackgroundTable() { + await this.page.getByRole("tab", { name: "Background" }).click(); + } + public getStartPage() { Review Comment: I'm not sure about the necessity of this locator, as the "StartPage" is the entire editor. I think a `get` method to retrieve the entire editor would be a better choice. For this you can add a `data-testid` or `id` on the `.storybook/preview.ts` decorator function. ########## packages/scesim-editor/tests/e2e/__fixtures__/resizing.ts: ########## @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page, Locator } from "@playwright/test"; + +interface Position { + x: number; + y: number; +} + +export class Resizing { + constructor(public page: Page) {} + + public async resizeCell(target: Locator, from: Position = { x: 0, y: 0 }, to: Position = { x: 0, y: 0 }) { Review Comment: Moving to `{ x: 0, y: 0 }` should be explicit on the caller. Also, giving default values can make the caller to forget to pass one of the args. ########## packages/scesim-editor/tests/e2e/scesimEditor/backgroundTable/contextMenu.spec.ts: ########## @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect } from "../../__fixtures__/base"; +import { AssetType } from "../../__fixtures__/editor"; +import { AddColumnPosition } from "../../__fixtures__/table"; + +test.describe("Background table context menu", () => { + test.describe("Context menu checks", () => { + test.beforeEach(async ({ editor, backgroundTable, table }) => { + await editor.createTestScenario(AssetType.DECISION, true); + await table.addPropertyColumn({ + targetCell: "PROPERTY (<Undefined>)", + position: AddColumnPosition.RIGHT, + nth: 0, + }); + await backgroundTable.fill({ content: "test", column: 1 }); + }); + + test("should render select context menu", async ({ contextMenu }) => { + await contextMenu.openOnCell({ rowNumber: "test test", columnNumber: 0 }); + await expect(contextMenu.getHeader({ header: "SELECTION" })).toBeAttached(); + await expect(contextMenu.getHeader({ header: "SCENARIO" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "FIELD" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "INSTANCE" })).not.toBeAttached(); + }); + + test("should render field context menu", async ({ contextMenu }) => { + await contextMenu.openOnProperty({ name: "PROPERTY (<Undefined>)", columnNumber: 1 }); + await expect(contextMenu.getHeader({ header: "SELECTION" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "SCENARIO" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "FIELD" })).toBeAttached(); + await expect(contextMenu.getHeader({ header: "INSTANCE" })).not.toBeAttached(); + }); + + test("should render instance context menu", async ({ contextMenu }) => { + await contextMenu.openOnInstance({ name: "INSTANCE-1 (<Undefined>)" }); + await expect(contextMenu.getHeader({ header: "SELECTION" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "SCENARIO" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "FIELD" })).not.toBeAttached(); + await expect(contextMenu.getHeader({ header: "INSTANCE" })).toBeAttached(); + }); + + test("should add and delete instance column left", async ({ table, backgroundTable, contextMenu }) => { + await expect(table.getColumnHeader({ name: "INSTANCE-3 (<Undefined>)" })).not.toBeAttached(); Review Comment: I could see this pattern in multiple tests. I'm not sure about the assertion before it begins, I guess this comes from the `boxed-expression-component` tests. I guess we can assume the Given part is correct. ########## packages/scesim-editor/tests/e2e/features/selection/contextMenu.spec.ts: ########## @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test, expect } from "../../__fixtures__/base"; +import { AssetType } from "../../__fixtures__/editor"; + +test.describe("Selection", () => { + test.describe("Context menu", () => { + test.beforeEach(async ({ editor, testScenarioTable }) => { + await editor.createTestScenario(AssetType.RULE); + await testScenarioTable.fill({ content: '"test"', rowLocatorInfo: "1", column: 1 }); + }); + + test.describe(() => { + test.beforeEach(async ({ clipboard, context, browserName }) => { + test.skip( + browserName !== "chromium", + "Playwright Webkit doesn't support clipboard permissions: https://github.com/microsoft/playwright/issues/13037" + ); + clipboard.setup(context, browserName); + }); + + test("should use copy from selection context menu", async ({ clipboard, contextMenu, table }) => { + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "copy" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + await table.deleteCellContent({ rowNumber: "1", columnNumber: 1 }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).not.toContainText("test"); + await clipboard.paste(); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + }); + + test("should use cut from selection context menu", async ({ clipboard, contextMenu, table }) => { + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "cut" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).not.toContainText("test"); + await table.selectCell({ rowNumber: "1", columnNumber: 1 }); + await clipboard.paste(); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + }); + + test("should use copy and paste from selection context menu", async ({ contextMenu, table }) => { + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "copy" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + await table.deleteCellContent({ rowNumber: "1", columnNumber: 1 }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).not.toContainText("test"); + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "paste" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + }); + + test("should use cut and paste from selection context menu", async ({ contextMenu, table }) => { + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "cut" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).not.toContainText("test"); + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "paste" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + }); + }); + + test("should use reset from selection context menu", async ({ contextMenu, table }) => { + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).toContainText("test"); + await contextMenu.select({ rowNumber: "1", columnNumber: 1, command: "reset" }); + await expect(table.getCell({ rowNumber: "1", columnNumber: 1 })).not.toContainText("test"); + }); Review Comment: I guess some enters would give it a better look. WDYT? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
