diff --git a/test/e2e/README.md b/test/e2e/README.md index 66c06f4..9541897 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -27,6 +27,12 @@ End-to-end runs require Cloudinary credentials in `process.env`. The `onPrepare` pnpm test:e2e ``` +To run a specific test file: + +```bash +pnpm test:e2e --spec [FILE_PATH] +``` + This will: 1. Download a VS Code binary (if not already cached in `.wdio-vscode-service/`) 2. Launch VS Code with the extension loaded @@ -56,6 +62,9 @@ test/e2e/ Tests use [Mocha](https://mochajs.org/) as the test framework and the `wdio-vscode-service` page objects to interact with VS Code. +VS Code page object references: +- [wdio-vscode-service API](https://jubilant-broccoli-www5lem.pages.github.io/vscode-po/index.html) — full API reference for the WebdriverIO VS Code service. + ```ts import { activityBarUtils } from '../src/utils/ActivityBarUtils.js' import { sideBarViewUtils } from '../src/utils/SideBarViewUtils.js' diff --git a/test/e2e/package.json b/test/e2e/package.json index 0242cf8..8a7eded 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -16,7 +16,7 @@ "wdio-vscode-service": "^6.1.4" }, "scripts": { - "test:e2e": "wdio run ./wdio.conf.ts", + "test:e2e": "rm -rf allure-report allure-results && wdio run ./wdio.conf.ts", "test:report": "allure generate allure-results -o allure-report --clean && allure open allure-report" } } \ No newline at end of file diff --git a/test/e2e/specs/loadMlAssets.spec.ts b/test/e2e/specs/loadMlAssets.spec.ts index 69fc2e8..a7cec77 100644 --- a/test/e2e/specs/loadMlAssets.spec.ts +++ b/test/e2e/specs/loadMlAssets.spec.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { CloudinarySDK } from '../src/sdks/cloudinarySDK.js'; -import { activityBarUtils } from '../src/utils/ActivityBarUtils.js' -import { sideBarViewUtils } from '../src/utils/SideBarViewUtils.js' +import { activityBarUtils } from '../src/vscodeComponentsUtils/ActivityBarUtils.js' +import { sideBarViewUtils } from '../src/vscodeComponentsUtils/SideBarViewUtils.js' import crypto from 'node:crypto'; import { pathUtils } from '../src/utils/pathUtils.js'; @@ -15,12 +15,20 @@ describe('Asset Explorer Tetsts', () => { let secondAssetPublicID = `e2e-test-ae-${crypto.randomUUID().substring(0, 8)}`; beforeEach(async () => { - await cloudinarySDK.V2.uploader.upload(path.join(pathUtils.getTestAssetsPath(), 'sample_png.png'), { public_id: firstAssetPublicID }); - await cloudinarySDK.V2.uploader.upload(path.join(pathUtils.getTestAssetsPath(), 'sample_png.png'), { public_id: secondAssetPublicID }); + try { + await cloudinarySDK.V2.uploader.upload(path.join(pathUtils.getTestAssetsPath(), 'sample_png.png'), { public_id: firstAssetPublicID }); + await cloudinarySDK.V2.uploader.upload(path.join(pathUtils.getTestAssetsPath(), 'sample_png.png'), { public_id: secondAssetPublicID }); + } catch (error) { + throw new Error('Error uploading assets:', error); + } }); afterEach(async () => { - await cloudinarySDK.V2.api.delete_resources([firstAssetPublicID, secondAssetPublicID]); + try { + await cloudinarySDK.V2.api.delete_resources([firstAssetPublicID, secondAssetPublicID]); + } catch (error) { + throw new Error('Error deleting assets:', error); + } }); /** @@ -35,6 +43,6 @@ describe('Asset Explorer Tetsts', () => { await sideBarViewUtils.validateSideBarViewTitle(expectedTitle); await sideBarViewUtils.validateContentItemsExist(expectedItems); - }); + }); }); diff --git a/test/e2e/specs/searchAssetFromSideBar.spec.ts b/test/e2e/specs/searchAssetFromSideBar.spec.ts new file mode 100644 index 0000000..1c14386 --- /dev/null +++ b/test/e2e/specs/searchAssetFromSideBar.spec.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import crypto from 'node:crypto'; +import { CloudinarySDK } from '../src/sdks/cloudinarySDK.js'; +import { activityBarUtils } from '../src/vscodeComponentsUtils/ActivityBarUtils.js'; +import { SideBarViewActions, sideBarViewUtils } from '../src/vscodeComponentsUtils/SideBarViewUtils.js'; +import { pathUtils } from '../src/utils/pathUtils.js'; +import { inputBoxUtils } from '../src/vscodeComponentsUtils/InputBoxUtils.js'; +import { browser } from '@wdio/globals'; + +describe('Search asset from side bar', () => { + + const cloudinarySDK = new CloudinarySDK(); + const assetPublicID = `${crypto.randomUUID().substring(0, 8)}`; + + beforeEach(async () => { + try { + await cloudinarySDK.V2.uploader.upload( + path.join(pathUtils.getTestAssetsPath(), 'sample_png.png'), + { public_id: assetPublicID } + ); + } catch (error) { + throw new Error('Error uploading asset:', error); + } + }); + + afterEach(async () => { + try { + await cloudinarySDK.V2.api.delete_resources([assetPublicID]); + } catch (error) { + throw new Error('Error deleting asset:', error); + } + }); + + it('should find the uploaded asset via sidebar search', async () => { + await activityBarUtils.openView('Cloudinary'); + + await sideBarViewUtils.clickAction(SideBarViewActions.SEARCH); + + await inputBoxUtils.fillAndConfirm(assetPublicID); + + await sideBarViewUtils.validateContentItemsExist(['Clear Search', assetPublicID]); + await sideBarViewUtils.validateContentItemsNumber(2); + }); +}); diff --git a/test/e2e/specs/uploadFromSideBarView.spec.ts b/test/e2e/specs/uploadFromSideBarView.spec.ts new file mode 100644 index 0000000..2ee7aa6 --- /dev/null +++ b/test/e2e/specs/uploadFromSideBarView.spec.ts @@ -0,0 +1,61 @@ +import path from "path"; +import { CloudinarySDK } from "../src/sdks/cloudinarySDK.js"; +import { pathUtils } from "../src/utils/pathUtils.js"; +import { SideBarViewActions, sideBarViewUtils } from "../src/vscodeComponentsUtils/SideBarViewUtils.js"; +import { activityBarUtils } from "../src/vscodeComponentsUtils/ActivityBarUtils.js"; +import { uploadToCloudinaryTab } from "../src/webViewTabs/UploadToCloudinaryTab.js"; +import * as fs from 'node:fs'; +import { expect } from "@wdio/globals"; +import allureReporter from '@wdio/allure-reporter' + +describe('Upload asset from side bar Upload button', () => { + + const cloudinarySDK = new CloudinarySDK(); + const assetPath = path.join(pathUtils.getTestAssetsPath(), 'sample_png.png'); + + const newFileName = `${crypto.randomUUID().substring(0, 8)}.png`; + const newFilePath = path.join(pathUtils.getTempFolderPath(), newFileName); + + const firstAssetPublicID = `e2e-test-ae-${crypto.randomUUID().substring(0, 8)}`; + + beforeEach(async () => { + try { + // Copy with a unique name so the sidebar shows the display name (not the public ID) in dynamic folder environments + fs.copyFileSync(assetPath, newFilePath); + } catch (error) { + throw new Error('Error copying asset:', error); + } + }); + + afterEach(async () => { + try { + await cloudinarySDK.V2.api.delete_resources([firstAssetPublicID]); + } catch (error) { + throw new Error('Error deleting assets:', error); + } + }); + + it('should upload an asset using the side bar Upload button with custom public ID', async () => { + await activityBarUtils.openView('Cloudinary'); + await sideBarViewUtils.clickAction(SideBarViewActions.UPLOAD); + + await uploadToCloudinaryTab.switchTo(); + await uploadToCloudinaryTab.openAdvancedOptions(); + await uploadToCloudinaryTab.fillCustomPublicId(firstAssetPublicID); + + await uploadToCloudinaryTab.uploadLocalFile(newFilePath); + + await uploadToCloudinaryTab.switchBack(); + + await activityBarUtils.openView('Cloudinary'); + + await sideBarViewUtils.clickAction(SideBarViewActions.REFRESH); + + await sideBarViewUtils.validateContentItemsExist([newFileName.replace('.png', '')]); + + await allureReporter.addStep('Validate that the asset was uploaded with the correct display name'); + const byPublicId = await cloudinarySDK.V2.api.resource(firstAssetPublicID); + expect(byPublicId.display_name).toBe(newFileName.replace('.png', '')); + }); +}); + diff --git a/test/e2e/src/utils/pathUtils.ts b/test/e2e/src/utils/pathUtils.ts index b9d5bc7..f25d8a1 100644 --- a/test/e2e/src/utils/pathUtils.ts +++ b/test/e2e/src/utils/pathUtils.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,6 +23,17 @@ class PathUtils { public getTestAssetsPath() { return path.join(__dirname, '..', '..', 'assets'); } + + /** + * Gets the path to the temp folder. + */ + public getTempFolderPath() { + const tempFolderPath = path.join(__dirname, '..', '..', 'temp'); + if (!fs.existsSync(tempFolderPath)) { + fs.mkdirSync(tempFolderPath, { recursive: true }); + } + return tempFolderPath; + } } export const pathUtils = new PathUtils(); diff --git a/test/e2e/src/utils/ActivityBarUtils.ts b/test/e2e/src/vscodeComponentsUtils/ActivityBarUtils.ts similarity index 100% rename from test/e2e/src/utils/ActivityBarUtils.ts rename to test/e2e/src/vscodeComponentsUtils/ActivityBarUtils.ts diff --git a/test/e2e/src/vscodeComponentsUtils/InputBoxUtils.ts b/test/e2e/src/vscodeComponentsUtils/InputBoxUtils.ts new file mode 100644 index 0000000..e84f9bf --- /dev/null +++ b/test/e2e/src/vscodeComponentsUtils/InputBoxUtils.ts @@ -0,0 +1,32 @@ +import { browser } from "@wdio/globals" +import { InputBox } from "wdio-vscode-service" +import allureReporter from '@wdio/allure-reporter' + +/** + * Utility class for interacting with the VS Code InputBox / Command Palette. + */ +class InputBoxUtils { + + /** + * Opens the VS Code command palette. + */ + public async getInputBox() { + await allureReporter.addStep('Get Input Box instance'); + const inputBox = await browser.$('.quick-input-widget input'); + await inputBox.waitForDisplayed(); + return inputBox; + } + + /** + * Fills an already-visible InputBox with text and confirms. + * Use when an action (e.g. Search) has already opened an InputBox. + */ + public async fillAndConfirm(text: string) { + await allureReporter.addStep(`Fill input box "${text}"`); + const inputBox = await this.getInputBox(); + await inputBox.setValue(text); + await browser.keys('Enter'); + } +} + +export const inputBoxUtils = new InputBoxUtils() diff --git a/test/e2e/src/utils/SideBarViewUtils.ts b/test/e2e/src/vscodeComponentsUtils/SideBarViewUtils.ts similarity index 65% rename from test/e2e/src/utils/SideBarViewUtils.ts rename to test/e2e/src/vscodeComponentsUtils/SideBarViewUtils.ts index 2e571d3..34dbaab 100644 --- a/test/e2e/src/utils/SideBarViewUtils.ts +++ b/test/e2e/src/vscodeComponentsUtils/SideBarViewUtils.ts @@ -2,6 +2,15 @@ import { browser, expect } from "@wdio/globals" import { TreeItem } from "wdio-vscode-service" import allureReporter from '@wdio/allure-reporter' +/** + * Actions available in the Side Bar View. + */ +export enum SideBarViewActions { + UPLOAD = ' Upload', + SEARCH = ' Search', + REFRESH = ' Refresh', +} + /** * Utility class for interacting with the Side Bar View in VS Code. */ @@ -53,11 +62,42 @@ class SideBarViewUtils { const itemLabels = await Promise.all( visibleItems.map(item => item.getLabel()) ); + for (const expected of expectedItems) { expect(itemLabels).toContain(expected); } } + /** + * Waits for the content of the Side Bar View to load. + */ + public async clickAction(action: SideBarViewActions) { + await allureReporter.addStep(`Click the "${action.trim()}" action button`); + const sideBarView = await this.getSideBarView(); + const titlePart = sideBarView.getTitlePart(); + const actionButton = await titlePart.elem.$(`.//*[@title='${action}' or @aria-label='${action}']`); + await actionButton.waitForClickable(); + await actionButton.click(); + } + + /** + * Validates that the Side Bar View contains exactly the expected items and no others. + */ + public async validateContentItemsNumber(expectedItemsNumber: number) { + await allureReporter.addStep(`Validate only ${expectedItemsNumber} items are visible`); + await this.waitContentToLoad(); + const content = await this.getSideBarViewContent(); + const sections = await content.getSections(); + const visibleItems = await sections[0].getVisibleItems() as TreeItem[]; + const itemLabels = await Promise.all( + visibleItems.map(item => item.getLabel()) + ); + + await browser.waitUntil(async () => { + return itemLabels.length === expectedItemsNumber; + }, { timeout: 15000, timeoutMsg: `Expected ${itemLabels.length} items, but got ${expectedItemsNumber} items` }) + } + /** * Waits for the content of the Side Bar View to load. */ diff --git a/test/e2e/src/vscodeComponentsUtils/WebViewUtils.ts b/test/e2e/src/vscodeComponentsUtils/WebViewUtils.ts new file mode 100644 index 0000000..b1ea8a4 --- /dev/null +++ b/test/e2e/src/vscodeComponentsUtils/WebViewUtils.ts @@ -0,0 +1,16 @@ +import { browser } from "@wdio/globals" + + +class WebViewUtils { + /** + * Gets the WebView instance. + */ + public async getWebView(title: string) { + const workbench = await browser.getWorkbench() + return browser.waitUntil(() => workbench.getWebviewByTitle(title), { + timeoutMsg: `WebView with title "${title}" not found`, + }) + } +} + +export const webViewUtils = new WebViewUtils() \ No newline at end of file diff --git a/test/e2e/src/webViewTabs/UploadToCloudinaryTab.ts b/test/e2e/src/webViewTabs/UploadToCloudinaryTab.ts new file mode 100644 index 0000000..5acd973 --- /dev/null +++ b/test/e2e/src/webViewTabs/UploadToCloudinaryTab.ts @@ -0,0 +1,72 @@ +import { browser } from "@wdio/globals" +import allureReporter from '@wdio/allure-reporter' +import { WebViewTabBase } from "./WebViewTabBase.js"; + +/** + * Utility class for interacting with the Upload to Cloudinary webview. + */ +class UploadToCloudinaryTab extends WebViewTabBase { + + constructor() { + super('Upload to Cloudinary'); + } + + /** + * Uploads a local file via the upload widget's file input. + * Uses the wdio-vscode-service WebView page object for iframe switching. + */ + public async uploadLocalFile(absoluteFilePath: string) { + await allureReporter.addStep(`Upload file: ${absoluteFilePath}`); + + const fileInput = await browser.$('#fileInput'); + await fileInput.waitForExist(); + await browser.execute((el: any) => { + el.style.display = 'block'; + el.style.opacity = '1'; + }, fileInput as any); + await fileInput.setValue(absoluteFilePath); + + await this.waitForAllUploadsToComplete(); + } + + /** + * Waits for all uploads to complete. + */ + public async waitForAllUploadsToComplete() { + await browser.waitUntil(async () => { + const statuses = await browser.$$('.queue-item__status'); + const count = await statuses.length; + if (count === 0) { + return false + }; + + const textPromises = await statuses.map(el => el.getText()); + const texts = await Promise.all(textPromises); + return texts.every(text => text === 'Complete'); + }, { timeoutMsg: 'Not all uploads completed in time' }); + } + + /** + * Opens the Advanced Options section. + */ + public async openAdvancedOptions() { + await allureReporter.addStep('Open Advanced Options'); + + const header = await browser.$('#advancedHeader'); + await header.waitForClickable(); + await header.click(); + } + + /** + * Fills the Custom Public ID input. + */ + public async fillCustomPublicId(publicId: string) { + await allureReporter.addStep(`Fill Custom Public ID: ${publicId}`); + + const input = await browser.$('#publicIdInput'); + await input.waitForExist(); + await input.setValue(publicId); + } +} + +export const uploadToCloudinaryTab = new UploadToCloudinaryTab() diff --git a/test/e2e/src/webViewTabs/WebViewTabBase.ts b/test/e2e/src/webViewTabs/WebViewTabBase.ts new file mode 100644 index 0000000..62712d8 --- /dev/null +++ b/test/e2e/src/webViewTabs/WebViewTabBase.ts @@ -0,0 +1,38 @@ +import { WebView } from "wdio-vscode-service"; +import { webViewUtils } from "../vscodeComponentsUtils/WebViewUtils.js"; + + +/** + * Base class for all webview tabs. + */ +export abstract class WebViewTabBase { + protected title: string; + protected webview: WebView | null = null; + + /** + * Constructor. + * @param title - The title of the webview tab. + */ + constructor(title: string) { + this.title = title; + } + + /** + * Switches to the webview tab. + */ + public async switchTo() { + this.webview = await webViewUtils.getWebView(this.title); + await this.webview.open(); + } + + /** + * Switches back to the main view. + */ + public async switchBack() { + if (!this.webview) { + throw new Error('WebView not opened. Call switchTo() first.'); + } + await this.webview.close(); + this.webview = null; + } +} \ No newline at end of file