From 40ab7d5221f46499ffdfb1e474992d9d064dbda1 Mon Sep 17 00:00:00 2001 From: Vladimir Lazar <106525396+cbr7@users.noreply.github.com> Date: Mon, 25 Nov 2024 16:38:12 +0100 Subject: [PATCH] chore(test): initial draft for catalog model download e2e (#2133) * chore(test): initial draft for catalog model download e2e Signed-off-by: Vladimir Lazar --- tests/playwright/src/ai-lab-extension.spec.ts | 79 ++++++++------ .../playwright/src/model/ai-lab-base-page.ts | 2 +- .../src/model/ai-lab-catalog-page.ts | 102 ++++++++++++++++++ .../src/model/ai-lab-navigation-bar.ts | 7 ++ tests/playwright/src/utils/webviewHandler.ts | 48 +++++++++ 5 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 tests/playwright/src/model/ai-lab-catalog-page.ts create mode 100644 tests/playwright/src/utils/webviewHandler.ts diff --git a/tests/playwright/src/ai-lab-extension.spec.ts b/tests/playwright/src/ai-lab-extension.spec.ts index c54afbc5c..defb084b8 100644 --- a/tests/playwright/src/ai-lab-extension.spec.ts +++ b/tests/playwright/src/ai-lab-extension.spec.ts @@ -17,7 +17,7 @@ ***********************************************************************/ import type { Page } from '@playwright/test'; -import type { NavigationBar, ExtensionsPage, Runner } from '@podman-desktop/tests-playwright'; +import type { NavigationBar, ExtensionsPage } from '@podman-desktop/tests-playwright'; import { expect as playExpect, test, @@ -29,6 +29,8 @@ import { import { AILabPage } from './model/ai-lab-page'; import type { AILabRecipesCatalogPage } from './model/ai-lab-recipes-catalog-page'; import { AILabExtensionDetailsPage } from './model/podman-extension-ai-lab-details-page'; +import type { AILabCatalogPage } from './model/ai-lab-catalog-page'; +import { handleWebview } from './utils/webviewHandler'; const AI_LAB_EXTENSION_OCI_IMAGE = process.env.EXTENSION_OCI_IMAGE ?? 'ghcr.io/containers/podman-desktop-extension-ai-lab:nightly'; @@ -36,8 +38,6 @@ const AI_LAB_EXTENSION_PREINSTALLED: boolean = process.env.EXTENSION_PREINSTALLE const AI_LAB_CATALOG_EXTENSION_LABEL: string = 'redhat.ai-lab'; const AI_LAB_CATALOG_EXTENSION_NAME: string = 'Podman AI Lab extension'; const AI_LAB_CATALOG_STATUS_ACTIVE: string = 'ACTIVE'; -const AI_LAB_NAVBAR_EXTENSION_LABEL: string = 'AI Lab'; -const AI_LAB_PAGE_BODY_LABEL: string = 'Webview AI Lab'; let webview: Page; let aiLabPage: AILabPage; @@ -109,14 +109,43 @@ test.describe.serial(`AI Lab extension installation and verification`, { tag: '@ }); }); - [ - 'Audio to Text', - 'ChatBot', - 'Summarizer', - 'Code Generation', - /* 'Object Detection', */ // Object detection does not work without model upload - /* RAG Chatbot seems */ // too demanding on resources - ].forEach(appName => { + ['ggerganov/whisper.cpp', 'facebook/detr-resnet-101'].forEach(modelName => { + test.describe.serial(`Model download and deletion`, () => { + let catalogPage: AILabCatalogPage; + + test.beforeEach(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => { + [page, webview] = await handleWebview(runner, page, navigationBar); + aiLabPage = new AILabPage(page, webview); + await aiLabPage.navigationBar.waitForLoad(); + + catalogPage = await aiLabPage.navigationBar.openCatalog(); + await catalogPage.waitForLoad(); + }); + + test(`Download ${modelName} model`, async () => { + test.setTimeout(310_000); + playExpect(await catalogPage.isModelDownloaded(modelName)).toBeFalsy(); + await catalogPage.downloadModel(modelName); + await playExpect + // eslint-disable-next-line sonarjs/no-nested-functions + .poll(async () => await waitForCatalogModel(modelName), { timeout: 300_000, intervals: [5_000] }) + .toBeTruthy(); + }); + + test(`Delete ${modelName} model`, async () => { + test.skip(isWindows, 'Model deletion is currently very buggy in azure cicd'); + test.setTimeout(310_000); + playExpect(await catalogPage.isModelDownloaded(modelName)).toBeTruthy(); + await catalogPage.deleteModel(modelName); + await playExpect + // eslint-disable-next-line sonarjs/no-nested-functions + .poll(async () => await waitForCatalogModel(modelName), { timeout: 300_000, intervals: [2_500] }) + .toBeFalsy(); + }); + }); + }); + + ['Audio to Text', 'ChatBot', 'Summarizer', 'Code Generation'].forEach(appName => { test.describe.serial(`AI Recipe installation`, () => { let recipesCatalogPage: AILabRecipesCatalogPage; @@ -181,26 +210,12 @@ async function deleteUnusedImages(navigationBar: NavigationBar): Promise { await playExpect.poll(async () => await imagesPage.getCountOfImagesByStatus('UNUSED'), { timeout: 60_000 }).toBe(0); } -async function handleWebview(runner: Runner, page: Page, navigationBar: NavigationBar): Promise<[Page, Page]> { - const aiLabPodmanExtensionButton = navigationBar.navigationLocator.getByRole('link', { - name: AI_LAB_NAVBAR_EXTENSION_LABEL, - }); - await playExpect(aiLabPodmanExtensionButton).toBeEnabled(); - await aiLabPodmanExtensionButton.click(); - await page.waitForTimeout(2_000); - - const webView = page.getByRole('document', { name: AI_LAB_PAGE_BODY_LABEL }); - await playExpect(webView).toBeVisible(); - await new Promise(resolve => setTimeout(resolve, 1_000)); - const [mainPage, webViewPage] = runner.getElectronApp().windows(); - await mainPage.evaluate(() => { - const element = document.querySelector('webview'); - if (element) { - (element as HTMLElement).focus(); - } else { - console.log(`element is null`); - } - }); +async function waitForCatalogModel(modelName: string): Promise { + const recipeCatalogOage = await aiLabPage.navigationBar.openRecipesCatalog(); + await recipeCatalogOage.waitForLoad(); + + const catalogPage = await aiLabPage.navigationBar.openCatalog(); + await catalogPage.waitForLoad(); - return [mainPage, webViewPage]; + return await catalogPage.isModelDownloaded(modelName); } diff --git a/tests/playwright/src/model/ai-lab-base-page.ts b/tests/playwright/src/model/ai-lab-base-page.ts index 78a0b2047..2f3925ee5 100644 --- a/tests/playwright/src/model/ai-lab-base-page.ts +++ b/tests/playwright/src/model/ai-lab-base-page.ts @@ -21,7 +21,7 @@ import type { Locator, Page } from '@playwright/test'; export abstract class AILabBasePage { readonly page: Page; readonly webview: Page; - heading: Locator; + readonly heading: Locator; constructor(page: Page, webview: Page, heading: string | undefined) { this.page = page; diff --git a/tests/playwright/src/model/ai-lab-catalog-page.ts b/tests/playwright/src/model/ai-lab-catalog-page.ts new file mode 100644 index 000000000..12c910dfa --- /dev/null +++ b/tests/playwright/src/model/ai-lab-catalog-page.ts @@ -0,0 +1,102 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Locator, Page } from '@playwright/test'; +import { expect as playExpect } from '@playwright/test'; +import { AILabBasePage } from './ai-lab-base-page'; +import { handleConfirmationDialog } from '@podman-desktop/tests-playwright'; + +export class AILabCatalogPage extends AILabBasePage { + readonly catalogTable: Locator; + readonly modelsGroup: Locator; + + constructor(page: Page, webview: Page) { + super(page, webview, 'Models'); + this.catalogTable = this.webview.getByRole('table', { name: 'model' }); + this.modelsGroup = this.catalogTable.getByRole('rowgroup').nth(1); + } + + async waitForLoad(): Promise { + await playExpect(this.heading).toBeVisible(); + await playExpect(this.catalogTable).toBeVisible(); + await playExpect(this.modelsGroup).toBeVisible(); + } + + async getModelRowByName(modelName: string): Promise { + const modelRows = await this.getAllModelRows(); + for (const modelRow of modelRows) { + const modelNameCell = modelRow.getByText(modelName, { exact: true }); + if ((await modelNameCell.count()) > 0) { + return modelRow; + } + } + + return undefined; + } + + async downloadModel(modelName: string): Promise { + const modelRow = await this.getModelRowByName(modelName); + if (!modelRow) { + throw new Error(`Model ${modelName} not found`); + } + const downloadButton = modelRow.getByRole('button', { name: 'Download Model' }); + await playExpect(downloadButton).toBeEnabled(); + await downloadButton.focus(); + await downloadButton.click(); + } + + async createModelService(modelName: string): Promise { + const modelRow = await this.getModelRowByName(modelName); + if (!modelRow) { + throw new Error(`Model ${modelName} not found`); + } + const createServiceButton = modelRow.getByRole('button', { name: 'Create Model Service' }); + await playExpect(createServiceButton).toBeEnabled(); + await createServiceButton.focus(); + await createServiceButton.click(); + + throw new Error('Not implemented'); + } + + async deleteModel(modelName: string): Promise { + const modelRow = await this.getModelRowByName(modelName); + if (!modelRow) { + throw new Error(`Model ${modelName} not found`); + } + const deleteButton = modelRow.getByRole('button', { name: 'Delete Model' }); + await playExpect(deleteButton).toBeEnabled(); + await deleteButton.focus(); + await deleteButton.click(); + await this.page.waitForTimeout(1_000); + await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm'); + } + + async isModelDownloaded(modelName: string): Promise { + const modelRow = await this.getModelRowByName(modelName); + if (!modelRow) { + return false; + } + + const deleteButton = modelRow.getByRole('button', { name: 'Delete Model' }); + return (await deleteButton.count()) > 0; + } + + private async getAllModelRows(): Promise { + return this.modelsGroup.getByRole('row').all(); + } +} diff --git a/tests/playwright/src/model/ai-lab-navigation-bar.ts b/tests/playwright/src/model/ai-lab-navigation-bar.ts index e735ba04f..640317a92 100644 --- a/tests/playwright/src/model/ai-lab-navigation-bar.ts +++ b/tests/playwright/src/model/ai-lab-navigation-bar.ts @@ -22,6 +22,7 @@ import { AILabBasePage } from './ai-lab-base-page'; import { AILabRecipesCatalogPage } from './ai-lab-recipes-catalog-page'; import { AiRunningAppsPage } from './ai-lab-running-apps-page'; import { AiModelServicePage } from './ai-lab-model-service-page'; +import { AILabCatalogPage } from './ai-lab-catalog-page'; export class AILabNavigationBar extends AILabBasePage { readonly navigationBar: Locator; @@ -64,4 +65,10 @@ export class AILabNavigationBar extends AILabBasePage { await this.servicesButton.click(); return new AiModelServicePage(this.page, this.webview); } + + async openCatalog(): Promise { + await playExpect(this.catalogButton).toBeEnabled(); + await this.catalogButton.click(); + return new AILabCatalogPage(this.page, this.webview); + } } diff --git a/tests/playwright/src/utils/webviewHandler.ts b/tests/playwright/src/utils/webviewHandler.ts new file mode 100644 index 000000000..7b4d9d3df --- /dev/null +++ b/tests/playwright/src/utils/webviewHandler.ts @@ -0,0 +1,48 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Page } from '@playwright/test'; +import type { NavigationBar, Runner } from '@podman-desktop/tests-playwright'; +import { expect as playExpect } from '@podman-desktop/tests-playwright'; + +export async function handleWebview(runner: Runner, page: Page, navigationBar: NavigationBar): Promise<[Page, Page]> { + const AI_LAB_NAVBAR_EXTENSION_LABEL: string = 'AI Lab'; + const AI_LAB_PAGE_BODY_LABEL: string = 'Webview AI Lab'; + + const aiLabPodmanExtensionButton = navigationBar.navigationLocator.getByRole('link', { + name: AI_LAB_NAVBAR_EXTENSION_LABEL, + }); + await playExpect(aiLabPodmanExtensionButton).toBeEnabled(); + await aiLabPodmanExtensionButton.click(); + await page.waitForTimeout(2_000); + + const webView = page.getByRole('document', { name: AI_LAB_PAGE_BODY_LABEL }); + await playExpect(webView).toBeVisible(); + await new Promise(resolve => setTimeout(resolve, 1_000)); + const [mainPage, webViewPage] = runner.getElectronApp().windows(); + await mainPage.evaluate(() => { + const element = document.querySelector('webview'); + if (element) { + (element as HTMLElement).focus(); + } else { + console.log(`element is null`); + } + }); + + return [mainPage, webViewPage]; +}