From cc7511d73a0c305df441572e433873fd4ef3b9e2 Mon Sep 17 00:00:00 2001 From: Andy Pickering Date: Mon, 1 Jun 2026 11:20:00 +0900 Subject: [PATCH] Migrate e2e tests from Cypress to Playwright Co-authored-by: Cursor --- .ai/spec/how/project-structure.md | 5 +- .claude/commands/test.md | 6 +- .claude/settings.json | 2 +- .cursor/skills/test/SKILL.md | 4 +- .dockerignore | 1 - .gitignore | 1 + .../lightspeed-console-pre-commit.yaml | 40 +- AGENTS.md | 10 +- cypress.config.ts | 99 - cypress/OWNERS | 2 - cypress/support/commands.ts | 326 --- cypress/support/e2e.js | 42 - cypress/tsconfig.json | 6 - package-lock.json | 2321 +---------------- package.json | 12 +- playwright.config.ts | 50 + renovate.json | 2 +- tests/.eslintrc.yml | 2 - tests/README.md | 54 +- tests/support/fixtures.ts | 177 ++ tests/support/global-setup.ts | 323 +++ tests/support/global-teardown.ts | 46 + tests/tests/lightspeed-install.cy.ts | 1429 ---------- tests/tests/lightspeed.spec.ts | 1187 +++++++++ tests/tsconfig.json | 4 +- tests/views/operator-hub-page.ts | 24 - tests/views/pages.ts | 58 - tests/views/utils.ts | 7 - 28 files changed, 1917 insertions(+), 4323 deletions(-) delete mode 100644 cypress.config.ts delete mode 100644 cypress/OWNERS delete mode 100644 cypress/support/commands.ts delete mode 100644 cypress/support/e2e.js delete mode 100644 cypress/tsconfig.json create mode 100644 playwright.config.ts delete mode 100644 tests/.eslintrc.yml create mode 100644 tests/support/fixtures.ts create mode 100644 tests/support/global-setup.ts create mode 100644 tests/support/global-teardown.ts delete mode 100644 tests/tests/lightspeed-install.cy.ts create mode 100644 tests/tests/lightspeed.spec.ts delete mode 100644 tests/views/operator-hub-page.ts delete mode 100644 tests/views/pages.ts delete mode 100644 tests/views/utils.ts diff --git a/.ai/spec/how/project-structure.md b/.ai/spec/how/project-structure.md index b42ece20..cfac8a48 100644 --- a/.ai/spec/how/project-structure.md +++ b/.ai/spec/how/project-structure.md @@ -88,9 +88,8 @@ and communicates with the OLS backend service via the console's plugin proxy. | Path | Purpose | |---|---| -| `tests/` | Cypress e2e test specs | -| `cypress/` | Cypress support files and fixtures | -| `cypress.config.ts` | Cypress configuration | +| `tests/` | Playwright e2e test specs, support fixtures, and page objects | +| `playwright.config.ts` | Playwright configuration | | `unit-tests/` | Unit tests using Node's built-in test runner. Tests for: redux-reducers, error handling, attachments | ## Data Flow diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 49da7427..6789dec2 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -1,4 +1,4 @@ -Run Cypress tests filtered by tag. +Run end-to-end tests filtered by tag. Arguments: [tag] - Optional tag to filter by (@core, @attach, etc.) or "all" to run all tests @@ -6,9 +6,9 @@ run all tests 1. If a tag argument was provided, use it. Otherwise, ask the user which tag to filter by (@core, @attach, etc. or "all" to run all tests) 2. If the tag is "all", run the tests using - `CYPRESS_SKIP_OLS_SETUP='true' npm run test-headless`. Otherwise, run the + `SKIP_OLS_SETUP='true' npm run test-headless`. Otherwise, run the tests using - `CYPRESS_SKIP_OLS_SETUP='true' npm run test-headless -- --expose grepTags="@"` + `SKIP_OLS_SETUP='true' npm run test-headless -- --grep "@"` 3. Report the test results Output a bullet point list of each test that was run with a ✅, ❌ or ⏭️ to diff --git a/.claude/settings.json b/.claude/settings.json index cc3c3e60..541b544b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "Bash(node --version)", "Bash(npm --version)", - "Bash(CYPRESS_SKIP_OLS_SETUP='true' npm run test-headless)", + "Bash(SKIP_OLS_SETUP='true' npm run test-headless)", "Bash(npm run build)", "Bash(npm run i18n)", "Bash(npm run lint-fix)", diff --git a/.cursor/skills/test/SKILL.md b/.cursor/skills/test/SKILL.md index 650b454b..79486562 100644 --- a/.cursor/skills/test/SKILL.md +++ b/.cursor/skills/test/SKILL.md @@ -1,8 +1,8 @@ --- name: test description: >- - Run Cypress tests filtered by tag. Use when the user asks to run tests, - run Cypress, or test a specific feature tag like @core or @attach. + Run end-to-end tests filtered by tag. Use when the user asks to run tests, + run Playwright, or test a specific feature tag like @core or @attach. --- Read and follow the instructions in `.claude/commands/test.md`. diff --git a/.dockerignore b/.dockerignore index c68df3d8..315c80c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,6 @@ .gitignore .tekton .vscode -cypress dist gui_test_screenshots node_modules diff --git a/.gitignore b/.gitignore index 8de65c7c..883c3ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ CLAUDE.local.md dist/ gui_test_screenshots/ node_modules/ +tests/.auth/ diff --git a/.tekton/integration-tests/lightspeed-console-pre-commit.yaml b/.tekton/integration-tests/lightspeed-console-pre-commit.yaml index f9135e4b..98b52d45 100644 --- a/.tekton/integration-tests/lightspeed-console-pre-commit.yaml +++ b/.tekton/integration-tests/lightspeed-console-pre-commit.yaml @@ -146,7 +146,7 @@ spec: env: - name: COMMIT_SHA value: $(params.commit) - image: cypress/browsers:26.0.0 + image: mcr.microsoft.com/playwright:v1.60.0-noble resources: limits: memory: 4Gi @@ -219,31 +219,29 @@ spec: - name: credentials mountPath: /credentials env: - - name: CYPRESS_KUBECONFIG_PATH + - name: KUBECONFIG_PATH value: "/credentials/$(steps.get-kubeconfig.results.kubeconfig)" - - name: CYPRESS_LOGIN_IDP + - name: LOGIN_IDP value: "kube:admin" - name: LLM_TOKEN_PATH value: "/var/run/openai/token" - - name: CYPRESS_CONSOLE_IMAGE + - name: CONSOLE_IMAGE value: "$(params.console-image)" - name: COMMIT_SHA value: "$(params.commit)" - name: PASSWORD_PATH value: "/credentials/$(steps.get-kubeconfig.results.passwordPath)" - - name: CYPRESS_BASE_URL + - name: BASE_URL value: "$(steps.get-kubeconfig.results.consoleURL)" resources: limits: memory: 8Gi - image: cypress/browsers:26.0.0 + image: mcr.microsoft.com/playwright:v1.60.0-noble script: | echo "COMMIT_SHA: ${COMMIT_SHA}" - echo "CYPRESS_BASE_URL: ${CYPRESS_BASE_URL}" - echo "CYPRESS_CONSOLE_IMAGE: ${CYPRESS_CONSOLE_IMAGE}" - echo "CYPRESS_KUBECONFIG_PATH: ${CYPRESS_KUBECONFIG_PATH}" - echo "---------------------------------------------" - cat "${CYPRESS_KUBECONFIG_PATH}" + echo "BASE_URL: ${BASE_URL}" + echo "CONSOLE_IMAGE: ${CONSOLE_IMAGE}" + echo "KUBECONFIG_PATH: ${KUBECONFIG_PATH}" echo "---------------------------------------------" wget --no-verbose -O oc.tar.gz https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/latest/openshift-client-linux.tar.gz \ && tar -xvzf oc.tar.gz \ @@ -276,18 +274,16 @@ spec: echo "---------------------------------------------" NODE_OPTIONS=--max-old-space-size=4096 npm ci --omit=optional --no-fund echo "---------------------------------------------" - npx cypress install - echo "---------------------------------------------" - export CYPRESS_LOGIN_PASSWORD=$(cat ${PASSWORD_PATH}) + export LOGIN_PASSWORD=$(cat ${PASSWORD_PATH}) set +e - NO_COLOR=1 npx cypress run + npx playwright test err_status=$? - echo -n "${err_status}" > /workspace/cypress-exit-code + echo -n "${err_status}" > /workspace/playwright-exit-code echo "---------------------------------------------" ls ./gui_test_screenshots mv ./gui_test_screenshots /workspace/artifacts/ set -e - echo "Cypress exit code: ${err_status}" + echo "Playwright exit code: ${err_status}" echo "---------------------------------------------" - name: gather-cluster-resources onError: continue @@ -334,23 +330,23 @@ spec: value: "quay.io/openshift-lightspeed/ols-console-artifacts:$(params.commit)" - name: credentials-volume-name value: ols-konflux-artifacts-bot-creds - - name: assert-cypress-succeeded + - name: assert-playwright-succeeded image: quay.io/konflux-qe-incubator/konflux-qe-tools:latest workingDir: "/workspace/" script: | #!/bin/bash set -euo pipefail - code_file="/workspace/cypress-exit-code" + code_file="/workspace/playwright-exit-code" if [ ! -f "$code_file" ]; then - echo "Cypress exit code file missing at $code_file" + echo "Playwright exit code file missing at $code_file" exit 99 fi code=$(cat "$code_file") if [ "$code" != "0" ]; then - echo "Cypress failed with exit code: $code" + echo "Playwright failed with exit code: $code" exit "$code" fi - echo "Cypress tests passed" + echo "Playwright tests passed" - name: fail-if-any-step-failed ref: resolver: git diff --git a/AGENTS.md b/AGENTS.md index 2a356af2..bc22a1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ All conversation state (chat history, attachments, etc.) is managed in Redux. - Put images and other assets in `src/assets/` - Modules and extensions exposed by the plugin are added to `console-extensions.json` -- End-to-end tests live in `tests/` and `cypress/` +- End-to-end tests live in `tests/` - Unit tests live in `unit-tests/` ### Coding style @@ -84,8 +84,6 @@ All conversation state (chat history, attachments, etc.) is managed in Redux. ### Running - Dependencies are installed by running `npm install` - - Then run `npx cypress install` to download the Cypress binary (required - because `.npmrc` sets `ignore-scripts=true`) - To run the project locally: - Run `npm run start` in one terminal - Starts the dev server for the plugin on port 9001 @@ -97,11 +95,13 @@ All conversation state (chat history, attachments, etc.) is managed in Redux. - The `start-console.sh` script includes a proxy configuration that routes requests through the console, avoiding CORS issues -### End-to-end tests (Cypress) +### End-to-end tests (Playwright) +- Run `npx playwright install` to download browser binaries (required because + `.npmrc` sets `ignore-scripts=true`) - To run all tests: `npm run test-headless` - To run just some tests filtered by tag: - `npm run test-headless -- --expose grepTags="@attach"` + `npm run test-headless -- --grep "@attach"` - See `tests/README.md` for full details and environment variables ### Unit tests diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 1ea9873d..00000000 --- a/cypress.config.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { defineConfig } from 'cypress'; -import { plugin as cypressGrepPlugin } from '@cypress/grep/plugin'; -import * as fs from 'fs'; -import * as console from 'console'; - -export default defineConfig({ - screenshotsFolder: './gui_test_screenshots/cypress/screenshots', - screenshotOnRunFailure: true, - trashAssetsBeforeRuns: true, - videosFolder: './gui_test_screenshots/cypress/videos', - video: true, - videoCompression: false, - reporter: 'mocha-junit-reporter', - reporterOptions: { - mochaFile: './gui_test_screenshots/junit_cypress-[hash].xml', - toConsole: false, - }, - expose: { - grepFilterSpecs: true, - }, - fixturesFolder: 'fixtures', - defaultCommandTimeout: 30000, - retries: { - runMode: 0, - openMode: 0, - }, - viewportWidth: 1440, - viewportHeight: 900, - e2e: { - baseUrl: 'http://localhost:9000', - setupNodeEvents(on, config) { - on( - 'before:browser:launch', - ( - browser = { - name: '', - family: 'chromium', - channel: '', - displayName: '', - version: '', - majorVersion: '', - path: '', - isHeaded: false, - isHeadless: false, - }, - launchOptions, - ) => { - if (browser.family === 'chromium' && browser.name !== 'electron') { - // Auto open devtools - launchOptions.args.push('--enable-precise-memory-info'); - if (browser.isHeadless) { - launchOptions.args.push('--no-sandbox'); - launchOptions.args.push('--disable-gl-drawing-for-tests'); - launchOptions.args.push('--disable-gpu'); - } - } - - return launchOptions; - }, - ); - // `on` is used to hook into various events Cypress emits - on('task', { - log(message) { - console.log(message); - return null; - }, - logError(message) { - console.error(message); - return null; - }, - logTable(data) { - console.table(data); - return null; - }, - }); - cypressGrepPlugin(config); - on('after:spec', (spec: Cypress.Spec, results: CypressCommandLine.RunResult) => { - if (results && results.video) { - // Do we have failures for any retry attempts? - const failures = results.tests.some((test) => - test.attempts.some((attempt) => attempt.state === 'failed'), - ); - if (!failures && fs.existsSync(results.video)) { - // Delete the video if the spec passed and no tests retried - fs.unlinkSync(results.video); - } - } - }); - return config; - }, - supportFile: './cypress/support/e2e.js', - specPattern: 'tests/**/*.cy.{js,jsx,ts,tsx}', - numTestsKeptInMemory: 1, - testIsolation: false, - experimentalModifyObstructiveThirdPartyCode: true, - experimentalOriginDependencies: true, - experimentalMemoryManagement: true, - }, -}); diff --git a/cypress/OWNERS b/cypress/OWNERS deleted file mode 100644 index 2d976d03..00000000 --- a/cypress/OWNERS +++ /dev/null @@ -1,2 +0,0 @@ -approvers: - - JoaoFula diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index ff788464..00000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ -import * as _ from 'lodash'; - -import { getApiUrl } from '../../src/config'; - -import Loggable = Cypress.Loggable; -import Timeoutable = Cypress.Timeoutable; -import Withinable = Cypress.Withinable; -import Shadow = Cypress.Shadow; - -export const CONVERSATION_ID = '5f424596-a4f9-4a3a-932b-46a768de3e7c'; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - byTestID( - selector: string, - options?: Partial, - ): Chainable; - adminCLI(command: string, options?); - login( - provider?: string, - username?: string, - password?: string, - oauthurl?: string, - ): Chainable; - interceptFeedback( - alias: string, - conversationId: string, - sentiment: number, - userFeedback: string, - userQuestionStartsWith: string, - ): Chainable; - interceptQuery( - alias: string, - query: string, - conversationId?: string | null, - attachments?: Array<{ attachment_type: string; content_type: string }>, - ): Chainable; - interceptQueryWithError( - alias: string, - query: string, - errorMessage: string, - ): Chainable; - interceptQueryWithApproval(alias: string, query: string): Chainable; - interceptToolApproval(alias: string, approved: boolean): Chainable; - interceptMCPQuery( - alias: string, - query: string, - toolName: string, - uiResourceUri: string, - conversationId?: string | null, - ): Chainable; - interceptMCPResources( - alias: string, - htmlContent: string, - serverName?: string, - uiResourceUri?: string, - ): Chainable; - } - } -} - -// Any command added below, must be added to global Cypress interface above - -Cypress.Commands.add( - 'byTestID', - (selector: string, options?: Partial) => { - cy.get(`[data-test="${selector}"]`, options); - }, -); - -Cypress.Commands.add( - 'login', - ( - provider: string = 'kube:admin', - username: string = 'kubeadmin', - password: string = Cypress.env('LOGIN_PASSWORD'), - oauthurl: string, - ) => { - cy.session( - [provider, username], - () => { - cy.visit(Cypress.config('baseUrl')); - cy.window().then( - { oauthurl }, - ( - win: any, // eslint-disable-line @typescript-eslint/no-explicit-any - ) => { - // Check if auth is disabled (for a local development environment) - if (win.SERVER_FLAGS?.authDisabled) { - cy.task('log', ' skipping login, console is running with auth disabled'); - return; - } - cy.exec( - `oc get node --selector=hypershift.openshift.io/managed --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ).then((result) => { - cy.log(result.stdout); - cy.task('log', result.stdout); - cy.log(oauthurl); - cy.task('log', oauthurl); - cy.origin( - oauthurl, - { args: { username, password } }, - // eslint-disable-next-line @typescript-eslint/no-shadow - ({ username, password }) => { - cy.get('#inputUsername').type(username); - cy.get('#inputPassword').type(password); - cy.get('button[type=submit]').click(); - }, - ); - }); - }, - ); - }, - { - cacheAcrossSpecs: true, - validate() { - cy.visit(Cypress.config('baseUrl')); - cy.byTestID('username').should('exist'); - }, - }, - ); - }, -); - -Cypress.Commands.add('adminCLI', (command: string, options?) => { - cy.log(`Run admin command: ${command}`); - cy.exec(`${command} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, options); -}); - -const MOCK_STREAMED_RESPONSE_BODY = `data: {"event": "start", "data": {"conversation_id": "${CONVERSATION_ID}"}} - -data: {"event": "token", "data": {"id": 0, "token": "Mock"}} - -data: {"event": "token", "data": {"id": 1, "token": " OLS"}} - -data: {"event": "token", "data": {"id": 2, "token": " response"}} - -data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} -`; - -type Attachment = { attachment_type: string; content_type: string }; - -Cypress.Commands.add( - 'interceptQuery', - ( - alias: string, - query: string, - conversationId: string | null = null, - attachments: Array = [], - ) => { - cy.intercept('POST', getApiUrl('/v1/streaming_query'), (request) => { - expect(request.body.media_type).to.equal('application/json'); - expect(request.body.conversation_id).to.equal(conversationId); - expect(request.body.query).to.include(query); - - expect(request.body.attachments).to.have.lengthOf(attachments.length); - attachments.forEach((a, i) => { - expect(request.body.attachments[i].attachment_type).to.equal(a.attachment_type); - expect(request.body.attachments[i].content_type).to.equal(a.content_type); - }); - - request.reply({ body: MOCK_STREAMED_RESPONSE_BODY, delay: 1000 }); - }).as(alias); - }, -); - -const MOCK_STREAMED_RESPONSE_WITH_ERROR_BODY = `data: {"event": "start", "data": {"conversation_id": "${CONVERSATION_ID}"}} - -data: {"event": "token", "data": {"id": 0, "token": "Partial"}} - -data: {"event": "token", "data": {"id": 1, "token": " response"}} - -data: {"event": "tool_call", "data": {"id": 123, "name": "ABC", "args": {"some_key": "some_value"}}} - -data: {"event": "tool_result", "data": {"id": 123, "content": "Tool response", "status": "success"}} - -data: {"event": "error", "data": "MOCK_ERROR_MESSAGE"} -`; - -Cypress.Commands.add( - 'interceptQueryWithError', - (alias: string, query: string, errorMessage: string) => { - cy.intercept('POST', getApiUrl('/v1/streaming_query'), (request) => { - expect(request.body.query).to.include(query); - const responseBody = MOCK_STREAMED_RESPONSE_WITH_ERROR_BODY.replace( - 'MOCK_ERROR_MESSAGE', - errorMessage, - ); - request.reply({ body: responseBody, delay: 500 }); - }).as(alias); - }, -); - -/* eslint-disable camelcase */ -const MOCK_STREAMED_RESPONSE_WITH_APPROVAL_BODY = `data: {"event": "start", "data": {"conversation_id": "5f424596-a4f9-4a3a-932b-46a768de3e7c"}} - -data: {"event": "token", "data": {"id": 0, "token": "Mock"}} - -data: {"event": "token", "data": {"id": 1, "token": " response"}} - -data: {"event": "tool_call", "data": {"id": "tool-123", "name": "mock_tool", "args": {"namespace": "default"}}} - -data: {"event": "approval_required", "data": {"approval_id": "abc", "tool_name": "mock_tool", "tool_description": "This action will list pods in the cluster.", "tool_args": {"namespace": "default"}}} - -data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} -`; -/* eslint-enable camelcase */ - -Cypress.Commands.add('interceptQueryWithApproval', (alias: string, query: string) => { - cy.intercept('POST', getApiUrl('/v1/streaming_query'), (request) => { - expect(request.body.media_type).to.equal('application/json'); - expect(request.body.query).to.include(query); - request.reply({ body: MOCK_STREAMED_RESPONSE_WITH_APPROVAL_BODY, delay: 500 }); - }).as(alias); -}); - -Cypress.Commands.add('interceptToolApproval', (alias: string, approved: boolean) => { - cy.intercept('POST', getApiUrl('/v1/tool-approvals/decision'), (request) => { - expect(request.body.approval_id).to.equal('abc'); - expect(request.body.approved).to.equal(approved); - request.reply({ statusCode: 200, body: {} }); - }).as(alias); -}); - -const USER_FEEDBACK_MOCK_RESPONSE = { body: { message: 'Feedback received' } }; - -Cypress.Commands.add( - 'interceptFeedback', - ( - alias: string, - conversationId: string, - sentiment: number, - userFeedback: string, - userQuestionStartsWith: string, - ) => { - cy.intercept('POST', getApiUrl('/v1/feedback'), (request) => { - expect(_.omit(request.body, 'user_question')).to.deep.equal({ - /* eslint-disable camelcase */ - conversation_id: conversationId, - sentiment, - user_feedback: userFeedback, - llm_response: 'Mock OLS response', - /* eslint-enable camelcase */ - }); - expect(request.body.user_question.startsWith(userQuestionStartsWith)).to.equal(true); - - request.reply(USER_FEEDBACK_MOCK_RESPONSE); - }).as(alias); - }, -); - -const MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE = `data: {"event": "start", "data": {"conversation_id": "CONVERSATION_ID"}} - -data: {"event": "token", "data": {"id": 0, "token": "Here"}} - -data: {"event": "token", "data": {"id": 1, "token": " is"}} - -data: {"event": "token", "data": {"id": 2, "token": " your"}} - -data: {"event": "token", "data": {"id": 3, "token": " MCP"}} - -data: {"event": "token", "data": {"id": 4, "token": " dashboard"}} - -data: {"event": "tool_call", "data": {"id": 1, "name": "TOOL_NAME", "server_name": "test-server", "args": {}}} - -data: {"event": "tool_result", "data": {"id": 1, "content": "Dashboard loaded", "status": "success", "server_name": "test-server", "tool_meta": {"ui": {"resourceUri": "UI_RESOURCE_URI"}}}} - -data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} -`; - -Cypress.Commands.add( - 'interceptMCPQuery', - ( - alias: string, - query: string, - toolName: string, - uiResourceUri: string, - conversationId: string | null = null, - ) => { - cy.intercept('POST', getApiUrl('/v1/streaming_query'), (request) => { - expect(request.body.media_type).to.equal('application/json'); - expect(request.body.conversation_id).to.equal(conversationId); - expect(request.body.query).to.include(query); - - const responseBody = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( - 'CONVERSATION_ID', - conversationId || CONVERSATION_ID, - ) - .replace('TOOL_NAME', toolName) - .replace('UI_RESOURCE_URI', uiResourceUri); - - request.reply({ body: responseBody, delay: 1000 }); - }).as(alias); - }, -); - -Cypress.Commands.add( - 'interceptMCPResources', - ( - alias: string, - htmlContent: string, - serverName: string = 'test-server', - uiResourceUri: string = 'mcp://test-server/resources/dashboard', - ) => { - cy.intercept('POST', getApiUrl('/v1/mcp-apps/resources'), (request) => { - /* eslint-disable camelcase */ - if (request.body.resource_uri !== uiResourceUri) { - // Not the request we're waiting for — let it fall through to older intercepts - return; - } - - Cypress.log({ - name: 'MCP Resources Request', - message: `server_name: ${request.body.server_name}, resource_uri: ${request.body.resource_uri}`, - }); - - expect(request.body.server_name).to.equal(serverName); - /* eslint-enable camelcase */ - - request.reply({ body: { content: htmlContent }, delay: 500 }); - }).as(alias); - }, -); diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js deleted file mode 100644 index dfff9974..00000000 --- a/cypress/support/e2e.js +++ /dev/null @@ -1,42 +0,0 @@ -/* global Cypress, cy, afterEach */ -import './commands'; -import { register } from '@cypress/grep'; - -register(); - -// Collect browser console errors and warnings for output after each test -const browserLogs = []; - -Cypress.on('window:before:load', (win) => { - // Prevent the guided tour popup from appearing in all tests - const settings = { - 'console.guidedTour': { - admin: { - completed: true, - }, - }, - }; - win.localStorage.setItem('console-user-settings', JSON.stringify(settings)); - - // Capture browser console errors and warnings - ['error', 'warn'].forEach((method) => { - const original = win.console[method].bind(win.console); - win.console[method] = (...args) => { - const msg = args - .map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))) - .join(' '); - browserLogs.push({ method, msg }); - original(...args); - }; - }); -}); - -// Output collected browser logs to terminal after each test -afterEach(() => { - if (browserLogs.length > 0) { - browserLogs.forEach(({ method, msg }) => { - cy.task('log', `[console.${method}] ${msg}`, { log: false }); - }); - browserLogs.length = 0; - } -}); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json deleted file mode 100644 index 1d684ca3..00000000 --- a/cypress/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "types": ["cypress", "node"] - }, - "include": ["**/*.ts"] -} diff --git a/package-lock.json b/package-lock.json index 4240f109..2b680590 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,12 +39,11 @@ "webpack-cli": "7.0.2" }, "devDependencies": { - "@cypress/grep": "6.0.0", + "@playwright/test": "1.60.0", "@types/node": "^22.19.17", "@types/react": "^18.3.27", "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", - "cypress": "15.14.2", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-i18next": "^6.1.3", @@ -52,7 +51,6 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "i18next-parser": "^9.4.0", - "mocha-junit-reporter": "2.2.1", "pluralize": "^8.0.0", "prettier": "^3.8.1", "stylelint": "^17.6.0", @@ -276,16 +274,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -345,21 +333,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.7.tgz", - "integrity": "sha512-ruZOnKO+ajVL/MVx+PwNBPOkrnXTXoWMtte1MBpegfCArhqOe3Bj52avVj1huLLxNKYKXYaSxZ2F+woK1ekXfw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -587,88 +560,6 @@ "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@cypress/grep": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@cypress/grep/-/grep-6.0.0.tgz", - "integrity": "sha512-n3PCeqt8OwmLFz310igbRUm3qDE5WJgM9LW+2ejdULfMu2Sudqg3UX8koC8/JU/+ZcJ5UbaQAap1Nbi0QvzXwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "find-test-names": "^1.28.18", - "globby": "^11.0.4" - }, - "peerDependencies": { - "cypress": ">=15.10.0" - } - }, - "node_modules/@cypress/grep/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@cypress/request": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", - "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~4.0.4", - "http-signature": "~1.4.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "~6.14.1", - "safe-buffer": "^5.1.2", - "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@cypress/xvfb": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", - "dev": true, - "dependencies": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" - } - }, - "node_modules/@cypress/xvfb/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/@discoveryjs/json-ext": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.0.0.tgz", @@ -1952,6 +1843,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@posthog/core": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz", @@ -2346,18 +2253,6 @@ "@types/send": "*" } }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", - "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", - "dev": true - }, - "node_modules/@types/sizzle": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", - "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", - "dev": true - }, "node_modules/@types/sockjs": { "version": "0.3.36", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", @@ -2373,13 +2268,6 @@ "integrity": "sha512-Lja2xYuuf2B3knEsga8ShbOdsfNOtzT73GyJmZyY7eGl2+ajOqrs8yM5ze0fsSoYwvA6bw7/Qr7OZ7PEEmYwWg==", "dev": true }, - "node_modules/@types/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2408,16 +2296,6 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", @@ -3057,32 +2935,6 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -3137,26 +2989,6 @@ "node": ">= 8" } }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3293,16 +3125,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, "node_modules/asn1js": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", @@ -3318,16 +3140,6 @@ "node": ">=12.0.0" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3338,22 +3150,6 @@ "node": ">=8" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -3378,23 +3174,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "dev": true, - "license": "MIT" - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3454,16 +3233,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -3523,19 +3292,6 @@ "node": ">= 6" } }, - "node_modules/blob-util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, - "license": "MIT" - }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -3717,13 +3473,6 @@ "node": "10.* || >= 12.*" } }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "peer": true - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3757,39 +3506,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3844,15 +3560,6 @@ "qified": "^0.9.0" } }, - "node_modules/cachedir": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3912,19 +3619,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", @@ -3945,13 +3639,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -4063,15 +3750,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/cheerio": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", @@ -4147,193 +3825,37 @@ "node": ">=6.0" } }, - "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.8" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", "dependencies": { - "restore-cursor": "^5.0.0" + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, - "node_modules/cli-table3": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", - "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", - "dev": true, - "license": "MIT", + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "dependencies": { - "string-width": "^4.2.0" + "isobject": "^3.0.1" }, "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "colors": "1.4.0" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "peer": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clone-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=0.10.0" } }, "node_modules/clone-stats": { @@ -4392,19 +3914,6 @@ "node": ">=0.1.90" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -4439,15 +3948,6 @@ "node": ">= 6" } }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4672,15 +4172,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/css-functions-list": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", @@ -4798,156 +4289,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/cypress": { - "version": "15.14.2", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.14.2.tgz", - "integrity": "sha512-xMWg/iEImeIThRQZdnf3BFJT1a84apM/R91Feoa4vVWGuYWDphMT5jLhRVTBVlCgi+6axegF1zqhNyjhug2SsQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@cypress/request": "^3.0.10", - "@cypress/xvfb": "^1.2.4", - "@types/sinonjs__fake-timers": "8.1.1", - "@types/sizzle": "^2.3.2", - "@types/tmp": "^0.2.3", - "arch": "^2.2.0", - "blob-util": "^2.0.2", - "bluebird": "^3.7.2", - "buffer": "^5.7.1", - "cachedir": "^2.4.0", - "chalk": "^4.1.0", - "ci-info": "^4.1.0", - "cli-table3": "0.6.1", - "commander": "^6.2.1", - "common-tags": "^1.8.0", - "dayjs": "^1.10.4", - "debug": "^4.3.4", - "eventemitter2": "6.4.7", - "execa": "4.1.0", - "executable": "^4.1.1", - "extract-zip": "2.0.1", - "fs-extra": "^9.1.0", - "hasha": "5.2.2", - "is-installed-globally": "~0.4.0", - "listr2": "^9.0.5", - "lodash": "^4.17.23", - "log-symbols": "^4.0.0", - "minimist": "^1.2.8", - "ospath": "^1.2.2", - "pretty-bytes": "^5.6.0", - "process": "^0.11.10", - "proxy-from-env": "1.0.0", - "request-progress": "^3.0.0", - "supports-color": "^8.1.1", - "systeminformation": "^5.31.1", - "tmp": "~0.2.4", - "tree-kill": "1.2.2", - "tslib": "1.14.1", - "untildify": "^4.0.0", - "yauzl": "^2.10.0" - }, - "bin": { - "cypress": "bin/cypress" - }, - "engines": { - "node": "^20.1.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/cypress/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cypress/node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cypress/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/cypress/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/cypress/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/cypress/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4999,12 +4340,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "dev": true - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5022,19 +4357,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -5127,16 +4449,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -5194,19 +4506,6 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -5319,17 +4618,6 @@ "node": ">= 0.4" } }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5383,15 +4671,6 @@ "node": ">=0.10.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5443,19 +4722,6 @@ "node": ">=4" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eol": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz", @@ -6190,12 +5456,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter2": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", - "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", - "dev": true - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -6211,41 +5471,6 @@ "node": ">=0.8.x" } }, - "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, - "dependencies": { - "pify": "^2.2.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -6325,54 +5550,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6468,15 +5645,6 @@ "node": ">=0.8.0" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fflate": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", @@ -6546,43 +5714,6 @@ "node": ">= 0.8" } }, - "node_modules/find-test-names": { - "version": "1.28.30", - "resolved": "https://registry.npmjs.org/find-test-names/-/find-test-names-1.28.30.tgz", - "integrity": "sha512-b5PLJ5WnskdaYHBf+38FN/4TKh5lqwrltITkqxuARsN2bW6civrhqOXbVA+4727YNowYLt/jtIC9Dsn7eJSP6A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.24.7", - "@babel/plugin-syntax-jsx": "^7.24.7", - "acorn-walk": "^8.2.0", - "debug": "^4.3.3", - "globby": "^11.0.4", - "simple-bin-help": "^1.8.0" - }, - "bin": { - "find-test-names": "bin/find-test-names.js", - "print-tests": "bin/print-tests.js", - "update-test-count": "bin/update-test-count.js" - } - }, - "node_modules/find-test-names/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -6675,33 +5806,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6721,21 +5825,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fs-merger": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/fs-merger/-/fs-merger-3.2.1.tgz", @@ -6820,6 +5909,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6875,16 +5965,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "peer": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -6936,21 +6016,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -6968,16 +6033,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7048,30 +6103,6 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-dirs/node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -7131,37 +6162,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/globjoin": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", @@ -7302,33 +6302,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasha": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^2.0.0", - "type-fest": "^0.8.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hasha/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -7486,16 +6459,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "peer": true, - "bin": { - "he": "bin/he" - } - }, "node_modules/heimdalljs": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz", @@ -7723,30 +6686,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-signature": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", - "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.18.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "engines": { - "node": ">=8.12.0" - } - }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -8162,12 +7101,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -8345,22 +7278,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -8494,18 +7411,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -8554,29 +7459,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { + "node_modules/is-valid-glob": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-valid-glob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", - "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", + "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", + "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -8661,13 +7547,6 @@ "node": ">=0.10.0" } }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true, - "license": "MIT" - }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8741,13 +7620,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true, - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8774,13 +7646,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true, - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -8794,12 +7659,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8825,22 +7684,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, "node_modules/jsx-ast-utils": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz", @@ -8924,103 +7767,6 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "license": "MIT" }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -9059,175 +7805,12 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true - }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9326,17 +7909,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -10347,28 +8919,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -10388,30 +8938,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mktemp": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/mktemp/-/mktemp-0.4.0.tgz", @@ -10421,247 +8947,6 @@ "node": ">0.9" } }, - "node_modules/mocha": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", - "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-colors": "^4.1.3", - "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", - "debug": "^4.3.5", - "diff": "^5.2.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^8.1.0", - "he": "^1.2.0", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", - "ms": "^2.1.3", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/mocha-junit-reporter": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/mocha-junit-reporter/-/mocha-junit-reporter-2.2.1.tgz", - "integrity": "sha512-iDn2tlKHn8Vh8o4nCzcUVW4q7iXp7cC4EB78N0cDHIobLymyHNwe0XG8HEHHjc3hJlXm0Vy6zcrxaIhnI2fWmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "md5": "^2.3.0", - "mkdirp": "^3.0.0", - "strip-ansi": "^6.0.1", - "xml": "^1.0.1" - }, - "peerDependencies": { - "mocha": ">=2.2.5" - } - }, - "node_modules/mocha-junit-reporter/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/mocha/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mocha/node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/mocha/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "peer": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mocha/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "peer": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "peer": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "peer": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -10835,18 +9120,6 @@ "node": ">= 10.13.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -11006,21 +9279,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", @@ -11056,12 +9314,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ospath": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", - "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", - "dev": true - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -11274,29 +9526,6 @@ "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", "dev": true }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11314,15 +9543,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -11350,7 +9570,39 @@ "tslib": "^2.8.1" }, "engines": { - "node": ">=16.0.0" + "node": ">=16.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/pluralize": { @@ -11571,27 +9823,6 @@ "node": ">=6.0.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -11648,22 +9879,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", - "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true - }, - "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -11779,17 +9994,6 @@ "rimraf": "bin.js" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -12248,25 +10452,6 @@ "node": ">= 10" } }, - "node_modules/request-progress": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", - "dev": true, - "dependencies": { - "throttleit": "^1.0.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -12362,52 +10547,6 @@ "node": ">= 10.13.0" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -12428,12 +10567,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -12723,16 +10856,6 @@ "node": ">= 0.8" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/serve-index": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", @@ -12972,31 +11095,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/simple-bin-help": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/simple-bin-help/-/simple-bin-help-1.8.0.tgz", - "integrity": "sha512-0LxHn+P1lF5r2WwVB/za3hLRIsYoLaNq1CXqjbrs3ZvLuvlWnRKrUjEWzV7umZL7hpQ7xULiQMV+0iXdRa5iFg==", - "dev": true, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -13193,32 +11291,6 @@ } } }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -13419,15 +11491,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13874,33 +11937,6 @@ "url": "https://opencollective.com/synckit" } }, - "node_modules/systeminformation": { - "version": "5.31.6", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.6.tgz", - "integrity": "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==", - "dev": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32", - "freebsd", - "openbsd", - "netbsd", - "sunos", - "android" - ], - "bin": { - "systeminformation": "lib/cli.js" - }, - "engines": { - "node": ">=8.0.0" - }, - "funding": { - "type": "Buy me a coffee", - "url": "https://www.buymeacoffee.com/systeminfo" - } - }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -14048,15 +12084,6 @@ "tslib": "^2" } }, - "node_modules/throttleit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", - "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -14114,36 +12141,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14182,19 +12179,6 @@ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "license": "MIT" }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14217,15 +12201,6 @@ "tslib": "2" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -14382,26 +12357,6 @@ "devOptional": true, "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true, - "license": "Unlicense" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14769,15 +12724,6 @@ "node": ">= 0.8" } }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", @@ -14886,21 +12832,6 @@ "node": ">= 0.8" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -15482,31 +13413,6 @@ "node": ">=0.10.0" } }, - "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true, - "peer": true - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -15560,12 +13466,6 @@ } } }, - "node_modules/xml": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", - "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", - "dev": true - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -15576,16 +13476,6 @@ "node": ">=0.4" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -15593,71 +13483,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "peer": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "peer": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser/node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 42021f6d..e4ce9ec1 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "start-console": "./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", - "lint": "eslint ./*.{js,ts} ./src ./cypress ./tests ./unit-tests && stylelint \"src/**/*.css\" --allow-empty-input", - "lint-fix": "eslint ./*.{js,ts} ./src ./cypress ./tests ./unit-tests --fix && stylelint \"src/**/*.css\" --allow-empty-input --fix", - "test": "npx cypress open", - "test-headless": "npx cypress run --browser chrome", + "lint": "eslint ./*.{js,ts} ./src ./tests ./unit-tests && stylelint \"src/**/*.css\" --allow-empty-input", + "lint-fix": "eslint ./*.{js,ts} ./src ./tests ./unit-tests --fix && stylelint \"src/**/*.css\" --allow-empty-input --fix", + "test": "npx playwright test --ui", + "test-headless": "npx playwright test", "test:unit": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' node -r ts-node/register --test 'unit-tests/**/*.test.ts'" }, "dependencies": { @@ -52,12 +52,11 @@ "webpack-cli": "7.0.2" }, "devDependencies": { - "@cypress/grep": "6.0.0", + "@playwright/test": "1.60.0", "@types/node": "^22.19.17", "@types/react": "^18.3.27", "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", - "cypress": "15.14.2", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-i18next": "^6.1.3", @@ -65,7 +64,6 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "i18next-parser": "^9.4.0", - "mocha-junit-reporter": "2.2.1", "pluralize": "^8.0.0", "prettier": "^3.8.1", "stylelint": "^17.6.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..7cc9496e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs'; +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.BASE_URL || 'http://localhost:9000'; +const authStateFile = 'tests/.auth/state.json'; +const hasGlobalSetup = !process.env.SKIP_OLS_SETUP; +const storageState = hasGlobalSetup || fs.existsSync(authStateFile) ? authStateFile : undefined; + +if (!storageState) { + // eslint-disable-next-line no-console + console.warn( + `Warning: Auth state file "${authStateFile}" not found. Tests will run without stored authentication.`, + ); +} + +export default defineConfig({ + testDir: './tests/tests', + testMatch: '**/*.spec.ts', + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: [ + ['html', { outputFolder: 'gui_test_screenshots/playwright-report' }], + ['junit', { outputFile: 'gui_test_screenshots/junit_playwright.xml' }], + ], + outputDir: 'gui_test_screenshots/test-results', + globalSetup: process.env.SKIP_OLS_SETUP ? undefined : './tests/support/global-setup.ts', + globalTeardown: process.env.SKIP_OLS_SETUP ? undefined : './tests/support/global-teardown.ts', + use: { + baseURL, + storageState, + ignoreHTTPSErrors: true, + viewport: { width: 1440, height: 900 }, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + trace: 'retain-on-failure', + actionTimeout: 10_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/renovate.json b/renovate.json index 9a745170..d548ee1b 100644 --- a/renovate.json +++ b/renovate.json @@ -67,7 +67,7 @@ }, { "groupName": "test", - "matchPackageNames": ["@cypress/*", "cypress", "mocha-junit-reporter"] + "matchPackageNames": ["@playwright/*"] } ] } diff --git a/tests/.eslintrc.yml b/tests/.eslintrc.yml deleted file mode 100644 index cb847bf6..00000000 --- a/tests/.eslintrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -rules: - '@typescript-eslint/no-unused-expressions': 'off' diff --git a/tests/README.md b/tests/README.md index 18526b31..63abe3bc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,7 +1,7 @@ # OpenShift Lightspeed Console Tests These console tests install the OpenShift Lightspeed Operator in the specified -cluster and then run a series of Cypress e2e tests against the UI. +cluster and then run a series of Playwright e2e tests against the UI. ## Prerequisites @@ -10,63 +10,53 @@ cluster and then run a series of Cypress e2e tests against the UI. ## Install dependencies All required dependencies are defined in `package.json`. Run `npm install` to -install the dependencies in the `node_modules` folder. - -Because `.npmrc` sets `ignore-scripts=true` for security, Cypress's postinstall -script does not run automatically. You must explicitly install the Cypress binary -before running tests: - -```bash -npx cypress install -``` +install the dependencies in the `node_modules` folder. Then run +`npx playwright install` to download the browser binaries (required because +`.npmrc` sets `ignore-scripts=true`). ## Export necessary variables Test behavior can be customized by setting environment variables. If you are running the OpenShift Lightspeed UI locally (with login disabled), -you normally just need to run `npm run test` to run all the tests in the Cypress -GUI. +you normally just need to run `npm run test` to run all the tests in the +Playwright UI. If you are not running the OpenShift Lightspeed UI locally or otherwise need to customize how the tests run, you can use the following environment variables. -- `CYPRESS_BASE_URL=` +- `BASE_URL=` - Defaults to `http://localhost:9000`, which is the default base URL when running locally -- `CYPRESS_SKIP_OLS_SETUP=true` +- `SKIP_OLS_SETUP=true` - Skip login and operator installation, which is generally what you want when testing locally -- `CYPRESS_KUBECONFIG_PATH=/path/to/kubeconfig` -- `CYPRESS_LOGIN_IDP=kube:admin` - - Use `flexy-htpasswd-provider` when running tests on flexy installed clusters +- `KUBECONFIG_PATH=/path/to/kubeconfig` +- `LOGIN_IDP=kube:admin` + - Use `flexy-htpasswd-provider` when running tests on flexy-installed clusters and using any user other than kubeadmin. Use `kube:admin` when running tests as kubeadmin. -- `CYPRESS_LOGIN_USERNAME` - - e.g. `CYPRESS_LOGIN_USERNAME=kubeadmin` -- `CYPRESS_LOGIN_PASSWORD=` -- `CYPRESS_UI_INSTALL=true` - - If set, the tests will start by installing the OpenShift Lightspeed operator - through the UI -- `CYPRESS_BUNDLE_IMAGE=` +- `LOGIN_USERNAME` + - e.g. `LOGIN_USERNAME=kubeadmin` +- `LOGIN_PASSWORD=` +- `BUNDLE_IMAGE=` - If set, the tests will start by installing the OpenShift Lightspeed operator using the given Konflux bundle image - Use this if you are running the tests on a HyperShift cluster - The bundle image can be taken from [Konflux's bundle image](https://console.redhat.com/application-pipeline/workspaces/crt-nshift-lightspeed/applications/ols-bundle/components/test-bundle) -- `CYPRESS_CONSOLE_IMAGE=` +- `CONSOLE_IMAGE=` - If set, the OpenShift Lightspeed UI image installed by the operator will be replaced with this image before the tests are run ## Run tests -You can either open the Cypress GUI (`npm run test`) or run Cypress in headless -mode (`npm run test-headless`). +You can either open the Playwright UI (`npm run test`) or run Playwright in +headless mode (`npm run test-headless`). -You can limit which tests are run by tag by using `--expose grepTags="<@tags>"` -(Cypress CLI flag, passed after `--`). +You can limit which tests are run by tag using `--grep "<@tags>"`. -For example, `npm run test-headless -- --expose grepTags="@core @attach"` runs -the core functionality and attachment related tests in headless mode. +For example, `npm run test-headless -- --grep "@core|@attach"` runs the core +functionality and attachment-related tests in headless mode. -Artifacts (screenshots/videos) are saved in `gui_test_screenshots/cypress/`. +Artifacts (screenshots/videos/reports) are saved in `gui_test_screenshots/`. diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts new file mode 100644 index 00000000..2afc3907 --- /dev/null +++ b/tests/support/fixtures.ts @@ -0,0 +1,177 @@ +import { test as base, expect, type Page } from '@playwright/test'; +import { execFileSync } from 'child_process'; + +const API_BASE_URL = '/api/proxy/plugin/lightspeed-console-plugin/ols'; +const getApiUrl = (path: string): string => `${API_BASE_URL}${path}`; + +export const CONVERSATION_ID = '5f424596-a4f9-4a3a-932b-46a768de3e7c'; + +export const MOCK_STREAMED_RESPONSE_BODY = `data: {"event": "start", "data": {"conversation_id": "${CONVERSATION_ID}"}} + +data: {"event": "token", "data": {"id": 0, "token": "Mock"}} + +data: {"event": "token", "data": {"id": 1, "token": " OLS"}} + +data: {"event": "token", "data": {"id": 2, "token": " response"}} + +data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} +`; + +export const MOCK_STREAMED_RESPONSE_WITH_ERROR_BODY = `data: {"event": "start", "data": {"conversation_id": "${CONVERSATION_ID}"}} + +data: {"event": "token", "data": {"id": 0, "token": "Partial"}} + +data: {"event": "token", "data": {"id": 1, "token": " response"}} + +data: {"event": "tool_call", "data": {"id": 123, "name": "ABC", "args": {"some_key": "some_value"}}} + +data: {"event": "tool_result", "data": {"id": 123, "content": "Tool response", "status": "success"}} + +data: {"event": "error", "data": "MOCK_ERROR_MESSAGE"} +`; + +export const MOCK_STREAMED_RESPONSE_WITH_APPROVAL_BODY = `data: {"event": "start", "data": {"conversation_id": "5f424596-a4f9-4a3a-932b-46a768de3e7c"}} + +data: {"event": "token", "data": {"id": 0, "token": "Mock"}} + +data: {"event": "token", "data": {"id": 1, "token": " response"}} + +data: {"event": "tool_call", "data": {"id": "tool-123", "name": "mock_tool", "args": {"namespace": "default"}}} + +data: {"event": "approval_required", "data": {"approval_id": "abc", "tool_name": "mock_tool", "tool_description": "This action will list pods in the cluster.", "tool_args": {"namespace": "default"}}} + +data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} +`; + +export const MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE = `data: {"event": "start", "data": {"conversation_id": "CONVERSATION_ID"}} + +data: {"event": "token", "data": {"id": 0, "token": "Here"}} + +data: {"event": "token", "data": {"id": 1, "token": " is"}} + +data: {"event": "token", "data": {"id": 2, "token": " your"}} + +data: {"event": "token", "data": {"id": 3, "token": " MCP"}} + +data: {"event": "token", "data": {"id": 4, "token": " dashboard"}} + +data: {"event": "tool_call", "data": {"id": 1, "name": "TOOL_NAME", "server_name": "test-server", "args": {}}} + +data: {"event": "tool_result", "data": {"id": 1, "content": "Dashboard loaded", "status": "success", "server_name": "test-server", "tool_meta": {"ui": {"resourceUri": "UI_RESOURCE_URI"}}}} + +data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} +`; + +type Attachment = { attachment_type: string; content_type: string }; + +export const oc = (args: string[]): string => + execFileSync('oc', [...args, '--kubeconfig', process.env.KUBECONFIG_PATH!], { + encoding: 'utf-8', + timeout: 180_000, + }); + +export const interceptQuery = async ( + page: Page, + query: string, + conversationId: string | null = null, + attachments: Attachment[] = [], +): Promise => { + const { promise, resolve, reject } = Promise.withResolvers(); + const pattern = `**${getApiUrl('/v1/streaming_query')}`; + + await page.unroute(pattern); + await page.route( + pattern, + async (route) => { + try { + const body = route.request().postDataJSON(); + expect(body.media_type).toBe('application/json'); + expect(body.conversation_id).toBe(conversationId); + expect(body.query).toContain(query); + expect(body.attachments).toHaveLength(attachments.length); + attachments.forEach((a, i) => { + expect(body.attachments[i].attachment_type).toBe(a.attachment_type); + expect(body.attachments[i].content_type).toBe(a.content_type); + }); + + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_BODY }); + resolve(); + } catch (err) { + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_BODY }); + reject(err); + } + }, + { times: 1 }, + ); + + return promise; +}; + +export const interceptFeedback = async ( + page: Page, + conversationId: string, + sentiment: number, + userFeedback: string, + userQuestionStartsWith: string, +): Promise => { + const { promise, resolve, reject } = Promise.withResolvers(); + const pattern = `**${getApiUrl('/v1/feedback')}`; + + await page.unroute(pattern); + await page.route( + pattern, + async (route) => { + try { + const body = route.request().postDataJSON(); + expect(body.conversation_id).toBe(conversationId); + expect(body.sentiment).toBe(sentiment); + expect(body.user_feedback).toBe(userFeedback); + expect(body.llm_response).toBe('Mock OLS response'); + expect(body.user_question.startsWith(userQuestionStartsWith)).toBe(true); + await route.fulfill({ + status: 200, + body: JSON.stringify({ message: 'Feedback received' }), + }); + resolve(); + } catch (err) { + await route.fulfill({ + status: 200, + body: JSON.stringify({ message: 'Feedback received' }), + }); + reject(err); + } + }, + { times: 1 }, + ); + + return promise; +}; + +// Custom test fixture that captures browser console errors/warnings and prints +// them only when the test fails, keeping passing test output clean. +export const test = base.extend<{ captureConsoleLogs: void }>({ + captureConsoleLogs: [ + async ({ page }, use, testInfo) => { + const logs: { method: string; msg: string }[] = []; + + page.on('console', (msg) => { + const type = msg.type(); + if (type === 'error' || type === 'warning') { + logs.push({ method: type, msg: msg.text() }); + } + }); + + await use(); + + if (testInfo.status !== testInfo.expectedStatus && logs.length > 0) { + logs.forEach(({ method, msg }) => { + // eslint-disable-next-line no-console + console.log(`[console.${method}] ${msg}`); + }); + } + }, + { auto: true }, + ], +}); + +export { expect }; diff --git a/tests/support/global-setup.ts b/tests/support/global-setup.ts new file mode 100644 index 00000000..645d5fb5 --- /dev/null +++ b/tests/support/global-setup.ts @@ -0,0 +1,323 @@ +/* eslint-disable no-console */ +import { chromium, type FullConfig } from '@playwright/test'; +import { execFileSync, execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { oc } from './fixtures'; + +const MINUTE = 60 * 1000; +const AUTH_DIR = path.join(__dirname, '..', '.auth'); +const STATE_FILE = path.join(AUTH_DIR, 'state.json'); + +const globalSetup = async (config: FullConfig) => { + const baseURL = config.projects[0].use.baseURL!; + const username = process.env.LOGIN_USERNAME || 'kubeadmin'; + const KUBECONFIG = process.env.KUBECONFIG_PATH; + + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + oc(['adm', 'policy', 'add-cluster-role-to-user', 'cluster-admin', username]); + oc(['adm', 'policy', 'add-cluster-role-to-user', 'lightspeed-operator-query-access', username]); + + const oauthResult = oc([ + 'get', + 'oauthclient', + 'openshift-browser-client', + '-o', + 'go-template', + '--template={{index .redirectURIs 0}}', + ]); + const oauthOrigin = new URL(oauthResult.trim().replace(/"/g, '')).origin; + console.log(`OAuth origin: ${oauthOrigin}`); + + const OLS_NAMESPACE = 'openshift-lightspeed'; + const OLS_CONFIG_KIND = 'OLSConfig'; + const OLS_CONFIG_NAME = 'cluster'; + + const OLS_CONFIG_YAML = `apiVersion: ols.openshift.io/v1alpha1 +kind: ${OLS_CONFIG_KIND} +metadata: + name: ${OLS_CONFIG_NAME} +spec: + llm: + providers: + - type: openai + name: openai + credentialsSecretRef: + name: openai-api-keys + url: https://api.openai.com/v1 + models: + - name: gpt-4o-mini + ols: + defaultModel: gpt-4o-mini + defaultProvider: openai + logLevel: INFO`; + + // Check if operator is already installed + let operatorAlreadyInstalled = false; + try { + const csvCheck = oc(['get', 'csv', '--namespace', OLS_NAMESPACE]); + operatorAlreadyInstalled = + csvCheck.trim() !== '' && !csvCheck.toLowerCase().includes('no resources found'); + } catch { + operatorAlreadyInstalled = false; + } + + if (operatorAlreadyInstalled) { + console.log(`Operator already installed in ${OLS_NAMESPACE}. Skipping installation.`); + } else { + console.log('Operator not found. Proceeding with installation.'); + + try { + oc(['get', 'ns', OLS_NAMESPACE]); + } catch { + oc(['create', 'ns', OLS_NAMESPACE]); + } + oc([ + 'label', + 'namespaces', + OLS_NAMESPACE, + 'openshift.io/cluster-monitoring=true', + '--overwrite=true', + ]); + + const bundleImage = + process.env.BUNDLE_IMAGE || 'quay.io/openshift-lightspeed/lightspeed-operator-bundle:latest'; + try { + const result = execFileSync( + 'operator-sdk', + [ + 'run', + 'bundle', + '--timeout=10m', + '--namespace', + OLS_NAMESPACE, + bundleImage, + '--kubeconfig', + KUBECONFIG!, + ], + { encoding: 'utf-8', timeout: 12 * MINUTE }, + ); + console.log(`operator-sdk run bundle stdout:\n${result}`); + } catch (err: unknown) { + const error = err as { stdout?: string; stderr?: string }; + console.error(`operator-sdk run bundle failed:\n${error.stdout}\n${error.stderr}`); + throw err; + } + + // Replace console image in CSV if CONSOLE_IMAGE is set + if (process.env.CONSOLE_IMAGE) { + const csvName = oc([ + 'get', + 'clusterserviceversion', + '--namespace', + OLS_NAMESPACE, + '-o', + 'name', + ]) + .trim() + .split('\n') + .filter(Boolean)[0]; + + oc([ + 'scale', + '--replicas=0', + 'deployment/lightspeed-operator-controller-manager', + '--namespace', + OLS_NAMESPACE, + ]); + + const csvJson = oc(['get', csvName, '--namespace', OLS_NAMESPACE, '-o', 'json']); + const csv = JSON.parse(csvJson); + + const argToRelatedImage: Record = { + '--console-image-pf5': 'lightspeed-console-plugin-pf5', + '--console-image-4-19': 'lightspeed-console-plugin-4-19', + '--console-image=': 'lightspeed-console-plugin', + }; + + const args = csv.spec.install.spec.deployments[0].spec.template.spec.containers[0].args; + const relatedImages = csv.spec.relatedImages; + + const relatedImageOps: { op: string; path: string; value: string }[] = []; + for (const arg of args) { + for (const [prefix, riName] of Object.entries(argToRelatedImage)) { + if (arg.startsWith(prefix)) { + const idx = relatedImages.findIndex((ri: { name: string }) => ri.name === riName); + if (idx !== -1) { + relatedImageOps.push({ + op: 'replace', + path: `/spec/relatedImages/${idx}/image`, + value: process.env.CONSOLE_IMAGE!, + }); + } + } + } + } + + const updatedArgs = args.map((arg: string) => + arg.startsWith('--console-image') + ? arg.replace(/=.*/, `=${process.env.CONSOLE_IMAGE}`) + : arg, + ); + + const patch = JSON.stringify([ + ...relatedImageOps, + { + op: 'replace', + path: '/spec/install/spec/deployments/0/spec/template/spec/containers/0/args', + value: updatedArgs, + }, + ]); + const patchFile = '/tmp/ols-csv-patch.json'; + fs.writeFileSync(patchFile, patch); + oc([ + 'patch', + csvName, + '--namespace', + OLS_NAMESPACE, + '--type=json', + '--patch-file', + patchFile, + ]); + oc([ + 'scale', + '--replicas=1', + 'deployment/lightspeed-operator-controller-manager', + '--namespace', + OLS_NAMESPACE, + ]); + } + } + + // Handle OLSConfig + let configExists = false; + try { + const configCheck = oc(['get', OLS_CONFIG_KIND, OLS_CONFIG_NAME, '-o', 'json']); + const existingConfig = JSON.parse(configCheck); + if (existingConfig.metadata.deletionTimestamp) { + console.log('OLSConfig is being deleted. Waiting for deletion...'); + const deadline = Date.now() + 3 * MINUTE; + while (Date.now() < deadline) { + try { + oc(['get', OLS_CONFIG_KIND, OLS_CONFIG_NAME]); + } catch { + break; + } + console.log('Waiting for deletion...'); + execSync('sleep 5'); + } + configExists = false; + } else { + console.log('OLSConfig already exists. Using existing config.'); + configExists = true; + } + } catch { + configExists = false; + } + + if (!configExists) { + console.log('Creating OLSConfig...'); + const configFile = '/tmp/ols-config.yaml'; + fs.writeFileSync(configFile, OLS_CONFIG_YAML); + oc(['create', '-f', configFile]); + } + + // Create secret if it doesn't exist + try { + oc(['get', 'secret', 'openai-api-keys', '-n', OLS_NAMESPACE]); + console.log('Secret openai-api-keys already exists.'); + } catch { + console.log('Creating secret openai-api-keys...'); + oc([ + 'create', + 'secret', + 'generic', + 'openai-api-keys', + '--from-literal=apitoken=empty', + '-n', + OLS_NAMESPACE, + ]); + } + + // Log in via browser and save storageState + const browser = await chromium.launch(); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + await page.goto(baseURL); + + // Perform login + const idp = process.env.LOGIN_IDP || 'kube:admin'; + const password = process.env.LOGIN_PASSWORD!; + + // Select IDP if the login page shows identity provider selection + const idpLink = page.locator(`a:has-text("${idp}")`); + if (await idpLink.isVisible({ timeout: 10_000 }).catch(() => false)) { + await idpLink.click(); + } + + await page.locator('#inputUsername').fill(username); + await page.locator('#inputPassword').fill(password); + await page.locator('button[type=submit]').click(); + + // Wait for console to load + await page.waitForURL('**/'); + + // Dismiss guided tour and set localStorage to prevent it reappearing + const tourSettings = { + 'console.guidedTour': { admin: { completed: true } }, + }; + await page.evaluate((settings) => { + localStorage.setItem('console-user-settings', JSON.stringify(settings)); + }, tourSettings); + + const tourDismiss = page.locator('[data-test="tour-step-footer-secondary"]'); + if (await tourDismiss.isVisible({ timeout: 5000 }).catch(() => false)) { + await tourDismiss.click(); + } + + // Dismiss the "Welcome to the new OpenShift experience" tour modal (4.19+) + const skipTour = page.getByRole('button', { name: 'Skip tour' }); + if (await skipTour.isVisible({ timeout: 5000 }).catch(() => false)) { + await skipTour.click(); + } + + // Wait for the OLS button to confirm plugin is loaded, re-checking after + // any page reloads triggered by operator installation. + const olsButton = page.locator('[data-test="ols-plugin__popover-button"]'); + await olsButton.waitFor({ timeout: 5 * MINUTE }); + + // After initial detection, wait for the page to stabilize (no further + // reloads) by confirming the button remains present after a brief interval. + // The console can reload multiple times after operator installation, so we + // wait until the button has been continuously visible for several checks. + const STABLE_THRESHOLD = 3; + let stableCount = 0; + for (let i = 0; i < 12; i++) { + await page.waitForTimeout(10_000); + if (await olsButton.isVisible().catch(() => false)) { + if (++stableCount >= STABLE_THRESHOLD) { + break; + } + continue; + } + stableCount = 0; + // Page reloaded — wait for the button again + await olsButton.waitFor({ timeout: 2 * MINUTE }); + } + + // Dismiss any tour modals that appeared after stabilization reloads + if (await skipTour.isVisible({ timeout: 2_000 }).catch(() => false)) { + await skipTour.click(); + } + + // Re-capture auth state after stabilization so cookies/tokens are fresh + await context.storageState({ path: STATE_FILE }); + await browser.close(); + + console.log(`Auth state saved to ${STATE_FILE}`); +}; + +export default globalSetup; diff --git a/tests/support/global-teardown.ts b/tests/support/global-teardown.ts new file mode 100644 index 00000000..84c74415 --- /dev/null +++ b/tests/support/global-teardown.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-console */ +import { oc } from './fixtures'; + +const globalTeardown = async () => { + if (process.env.SKIP_OLS_SETUP) { + console.log('Skip OLS uninstall because SKIP_OLS_SETUP is true'); + return; + } + + const OLS_NAMESPACE = 'openshift-lightspeed'; + const username = process.env.LOGIN_USERNAME || 'kubeadmin'; + + try { + oc(['delete', '--timeout=2m', 'OLSConfig', 'cluster']); + } catch { + // Ignore errors during cleanup + } + + try { + oc(['delete', 'namespace', OLS_NAMESPACE]); + } catch { + // Ignore errors during cleanup + } + + try { + oc(['adm', 'policy', 'remove-cluster-role-from-user', 'cluster-admin', username]); + } catch { + // Ignore errors during cleanup + } + + try { + oc([ + 'adm', + 'policy', + 'remove-cluster-role-from-user', + 'lightspeed-operator-query-access', + username, + ]); + } catch { + // Ignore errors during cleanup + } + + console.log('OLS cleanup complete'); +}; + +export default globalTeardown; diff --git a/tests/tests/lightspeed-install.cy.ts b/tests/tests/lightspeed-install.cy.ts deleted file mode 100644 index 55c622b2..00000000 --- a/tests/tests/lightspeed-install.cy.ts +++ /dev/null @@ -1,1429 +0,0 @@ -import { CONVERSATION_ID } from '../../cypress/support/commands'; -import { operatorHubPage } from '../views/operator-hub-page'; -import { listPage, pages, setEditorContent } from '../views/pages'; - -const OLS = { - namespace: 'openshift-lightspeed', - packageName: 'lightspeed-operator', - operatorName: 'OpenShift Lightspeed Operator', - config: { - kind: 'OLSConfig', - name: 'cluster', - }, -}; - -const OLS_CONFIG_YAML = `apiVersion: ols.openshift.io/v1alpha1 -kind: ${OLS.config.kind} -metadata: - name: ${OLS.config.name} -spec: - llm: - providers: - - type: openai - name: openai - credentialsSecretRef: - name: openai-api-keys - url: https://api.openai.com/v1 - models: - - name: gpt-4o-mini - ols: - defaultModel: gpt-4o-mini - defaultProvider: openai - logLevel: INFO`; - -const popover = '[data-test="ols-plugin__popover"]'; -const mainButton = '[data-test="ols-plugin__popover-button"]'; -const minimizeButton = '[data-test="ols-plugin__popover-minimize-button"]'; -const expandButton = '[data-test="ols-plugin__popover-expand-button"]'; -const collapseButton = '[data-test="ols-plugin__popover-collapse-button"]'; -const clearChatButton = '[data-test="ols-plugin__clear-chat-button"]'; -const userChatEntry = '[data-test="ols-plugin__chat-entry-user"]'; -const aiChatEntry = '[data-test="ols-plugin__chat-entry-ai"]'; -const loadingIndicator = `${popover} .pf-chatbot__message-loading`; -const attachments = `${popover} .ols-plugin__prompt-attachments`; -const attachMenu = `.pf-chatbot__menu`; -const promptAttachment = `${attachments} .ols-plugin__context-label`; -const fileInput = '[data-test="ols-plugin__file-upload"]'; -const responseAction = `${popover} .pf-chatbot__button--response-action`; -const copyConversationButton = '[data-test="ols-plugin__copy-conversation-button"]'; -const copyConversationTooltip = '[data-test="ols-plugin__copy-conversation-tooltip"]'; -const copyResponseButton = `${responseAction}[aria-label=Copy]`; -const userFeedback = `${popover} .ols-plugin__feedback`; -const userFeedbackInput = `${userFeedback} textarea`; -const userFeedbackSubmit = `${userFeedback} button.pf-m-primary`; -const modal = '.ols-plugin__modal'; -const toolApprovalCard = `${popover} .ols-plugin__tool-call`; -const toolLabel = `${popover} .pf-v6-c-label`; - -const promptArea = `${popover} .ols-plugin__prompt`; -const attachButton = `${promptArea} .pf-chatbot__button--attach`; -const promptInput = `${promptArea} textarea`; -const modeToggle = `${popover} [data-test="ols-plugin__mode-toggle"]`; - -const podNamePrefix = 'console'; - -const MINUTE = 60 * 1000; - -const PROMPT_SUBMITTED = 'What is OpenShift?'; -const PROMPT_NOT_SUBMITTED = 'Test prompt that should not be submitted'; -const USER_FEEDBACK_SUBMITTED = 'Good answer!\nMultiple lines\n\n(@#$%^&*) 😀 文字'; - -const POPOVER_TITLE = 'Red Hat OpenShift Lightspeed'; -const FOOTER_TEXT = 'Always review AI generated content prior to use.'; -const PRIVACY_TEXT = - "OpenShift Lightspeed uses AI technology to help answer your questions. Do not include personal information or other sensitive information in your input. Interactions may be used to improve Red Hat's products or services."; -const WELCOME_TEXT = 'Welcome to OpenShift Lightspeed'; - -const CLEAR_CHAT_TEXT = - 'Are you sure you want to erase the current chat conversation and start a new chat? This action cannot be undone.'; -const CLEAR_CHAT_CONFIRM_BUTTON = 'Erase and start new chat'; - -const READINESS_TITLE = 'Waiting for OpenShift Lightspeed service'; -const READINESS_TEXT = - 'The OpenShift Lightspeed service is not yet ready to receive requests. If this message persists, please check the OLSConfig.'; - -const ACM_ATTACH_CLUSTER_TEXT = 'Attach cluster info'; - -const USER_FEEDBACK_TEXT = - "Do not include personal information or other sensitive information in your feedback. Feedback may be used to improve Red Hat's products or services."; -const USER_FEEDBACK_RECEIVED_TEXT = 'Feedback submitted'; -const THUMBS_DOWN = -1; -const THUMBS_UP = 1; - -const MOCK_STREAMED_RESPONSE_TEXT = 'Mock OLS response'; -const MOCK_PARTIAL_RESPONSE_TEXT = 'Partial response'; -const MOCK_ERROR_MESSAGE = 'Service temporarily unavailable'; - -describe('OLS UI', () => { - before(() => { - if (Cypress.env('SKIP_OLS_SETUP')) { - cy.task('log', 'Skip OLS install and configuration because CYPRESS_SKIP_OLS_SETUP is true'); - } else { - cy.adminCLI( - `oc adm policy add-cluster-role-to-user cluster-admin ${Cypress.env('LOGIN_USERNAME')}`, - ); - - // Grant OLS query access permissions - cy.adminCLI( - `oc adm policy add-cluster-role-to-user lightspeed-operator-query-access ${Cypress.env('LOGIN_USERNAME')}`, - ); - - // Get OAuth URL for HyperShift cluster login - cy.exec( - `oc get oauthclient openshift-browser-client -o go-template --template="{{index .redirectURIs 0}}" --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ).then((result) => { - if (result.stderr === '') { - const oauthurl = new URL(result.stdout); - const oauthorigin = oauthurl.origin; - cy.task('log', `oauthorigin: "${oauthorigin}"`); - cy.wrap(oauthorigin).as('oauthorigin'); - } else { - throw new Error(`Execution of oc get oauthclient failed - Exit code: ${result.exitCode} - Stdout:\n${result.stdout} - Stderr:\n${result.stderr}`); - } - }); - cy.get('@oauthorigin').then((oauthorigin) => { - cy.login( - Cypress.env('LOGIN_IDP'), - Cypress.env('LOGIN_USERNAME'), - Cypress.env('LOGIN_PASSWORD'), - String(oauthorigin), - ); - }); - - // Check if operator is already installed by verifying csv exists - // We check any csv in the namespace and not by name due to csv being suffixed with version - cy.exec( - `oc get csv --namespace=${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - { failOnNonZeroExit: false }, - ).then((subscriptionCheck) => { - cy.task('log', `CSV check exit code: ${subscriptionCheck.exitCode}`); - cy.task('log', `CSV check stdout: ${subscriptionCheck.stdout}`); - cy.task('log', `CSV check stderr: ${subscriptionCheck.stderr}`); - - const operatorAlreadyInstalled = - subscriptionCheck.exitCode === 0 && - subscriptionCheck.stdout.trim() !== '' && - !subscriptionCheck.stdout.toLowerCase().includes('no resources found'); - - if (operatorAlreadyInstalled) { - cy.task( - 'log', - `Operator subscription already exists in ${OLS.namespace} namespace. Skipping installation and image substitution.`, - ); - } else { - cy.task('log', 'Operator not found. Proceeding with installation.'); - - // If UI_INSTALL exists, install via UI - // If running in nudges or pre-release, install with BUNDLE_IMAGE - // Otherwise install the latest operator - if (Cypress.env('UI_INSTALL')) { - operatorHubPage.installOperator(OLS.packageName, 'redhat-operators'); - cy.get('.co-clusterserviceversion-install__heading', { timeout: 5 * MINUTE }).should( - 'include.text', - 'ready for use', - ); - } else { - cy.exec( - `oc get ns ${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')} || oc create ns ${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - cy.exec( - `oc label namespaces ${OLS.namespace} openshift.io/cluster-monitoring=true --overwrite=true --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - const bundleImage = - Cypress.env('BUNDLE_IMAGE') || - 'quay.io/openshift-lightspeed/lightspeed-operator-bundle:latest'; - cy.exec( - `operator-sdk run bundle --timeout=10m --namespace ${OLS.namespace} ${bundleImage} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - { failOnNonZeroExit: false, timeout: 12 * MINUTE }, - ).then((result) => { - cy.task('log', `\n"operator-sdk run bundle" stdout:\n${result.stdout}\n`) - .task('log', `"operator-sdk run bundle" stderr:\n${result.stderr}\n`) - .then(() => { - if (result.exitCode !== 0) { - throw new Error( - `"operator-sdk run bundle" failed with exit code ${result.exitCode}`, - ); - } - }); - }); - } - - // If the console image exists, replace image in CSV and restart operator - // Console pod will restart automatically. - if (Cypress.env('CONSOLE_IMAGE')) { - cy.exec( - `oc get clusterserviceversion --namespace=${OLS.namespace} -o name --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ).then((result) => { - if (result.stderr === '') { - const csvName = result.stdout.trim().split('\n').filter(Boolean)[0]; - // Fetch the CSV, discover the relatedImages index for the - // console image dynamically, then apply a single atomic patch - // that updates both relatedImages and the console-image arg. - cy.exec( - `oc scale --replicas=0 deployment/lightspeed-operator-controller-manager --namespace=${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - cy.exec( - `oc get ${csvName} --namespace=${OLS.namespace} -o json --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ).then((csvResult) => { - if (csvResult.stderr !== '') { - throw new Error(`Getting csv failed - Exit code: ${csvResult.exitCode} - Stdout:\n${csvResult.stdout} - Stderr:\n${csvResult.stderr}`); - } - - const csv = JSON.parse(csvResult.stdout); - - // Map --console-image* arg prefixes to their relatedImages names - const argToRelatedImage: Record = { - '--console-image-pf5': 'lightspeed-console-plugin-pf5', - '--console-image-4-19': 'lightspeed-console-plugin-4-19', - '--console-image=': 'lightspeed-console-plugin', - }; - - const args = - csv.spec.install.spec.deployments[0].spec.template.spec.containers[0].args; - const relatedImages = csv.spec.relatedImages; - - // Collect relatedImages indices that match any console-image arg - const relatedImageOps: { op: string; path: string; value: string }[] = []; - for (const arg of args) { - for (const [prefix, riName] of Object.entries(argToRelatedImage)) { - if (arg.startsWith(prefix)) { - const idx = relatedImages.findIndex( - (ri: { name: string }) => ri.name === riName, - ); - if (idx !== -1) { - relatedImageOps.push({ - op: 'replace', - path: `/spec/relatedImages/${idx}/image`, - value: Cypress.env('CONSOLE_IMAGE'), - }); - } - } - } - } - - const updatedArgs = args.map((arg: string) => - arg.startsWith('--console-image') - ? arg.replace(/=.*/, `=${Cypress.env('CONSOLE_IMAGE')}`) - : arg, - ); - - const patch = JSON.stringify([ - ...relatedImageOps, - { - op: 'replace', - path: '/spec/install/spec/deployments/0/spec/template/spec/containers/0/args', - value: updatedArgs, - }, - ]); - cy.exec( - `oc patch ${csvName} --namespace=${OLS.namespace} --type='json' -p='${patch}' --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - - cy.exec( - `oc scale --replicas=1 deployment/lightspeed-operator-controller-manager --namespace=${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - }); - } else { - throw new Error(`Getting CSV name failed - Exit code: ${result.exitCode} - Stdout:\n${result.stdout} - Stderr:\n${result.stderr}`); - } - }); - } - } - }); - - // Check if OLSConfig exists and handle accordingly - cy.exec( - `oc get ${OLS.config.kind} ${OLS.config.name} -o json --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - { failOnNonZeroExit: false }, - ).then((configCheck) => { - if (configCheck.exitCode === 0) { - // OLSConfig exists, check if it's being deleted - const existingConfig = JSON.parse(configCheck.stdout); - if (existingConfig.metadata.deletionTimestamp) { - cy.task( - 'log', - `OLSConfig is being deleted. Waiting for deletion to complete before recreating...`, - ); - // Wait for deletion to complete (check every 5 seconds for up to 3 minutes) - cy.exec( - `timeout 180 bash -c 'until ! oc get ${OLS.config.kind} ${OLS.config.name} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')} 2>/dev/null; do echo "Waiting for deletion..."; sleep 5; done'`, - { timeout: 4 * MINUTE }, - ).then(() => { - cy.task('log', 'OLSConfig deleted. Creating new OLSConfig...'); - cy.exec( - `echo '${OLS_CONFIG_YAML}' | oc create -f - --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - }); - } else { - cy.task('log', `OLSConfig already exists and is not deleting. Using existing config.`); - } - } else { - // OLSConfig doesn't exist, create it - cy.task('log', 'OLSConfig not found. Creating new OLSConfig...'); - cy.exec( - `echo '${OLS_CONFIG_YAML}' | oc create -f - --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - } - }); - - // Create secret if it doesn't exist - cy.exec( - `oc get secret openai-api-keys -n ${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - { failOnNonZeroExit: false }, - ).then((secretCheck) => { - if (secretCheck.exitCode === 0) { - cy.task('log', 'Secret openai-api-keys already exists. Skipping creation.'); - } else { - cy.task('log', 'Creating secret openai-api-keys...'); - cy.exec( - `oc create secret generic openai-api-keys --from-literal=apitoken=empty -n ${OLS.namespace} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - ); - } - }); - - cy.visit('/'); - // Dismiss tour if it appears. The tour renders asynchronously after the - // page loads, so a single synchronous jQuery check can miss it. Instead, - // retry the check multiple times with a delay to give the tour time to - // render before concluding it won't appear. - cy.get(mainButton, { timeout: 5 * MINUTE }).should('exist'); - const tourSelector = '[data-test="tour-step-footer-secondary"]'; - const dismissTour = (retriesLeft: number): void => { - cy.get('body').then(($body) => { - if ($body.find(tourSelector).length > 0) { - cy.wrap($body).find(tourSelector).click(); - } else if (retriesLeft > 0) { - cy.wait(1000); - dismissTour(retriesLeft - 1); - } - }); - }; - dismissTour(30); - - // Wait 2 minutes for the page to reload so it doesn't happen during tests - cy.wait(120000); - } - }); - - after(() => { - if (Cypress.env('SKIP_OLS_SETUP')) { - cy.task('log', 'Skip OLS uninstall because CYPRESS_SKIP_OLS_SETUP is true'); - } else { - // Delete config first, making sure the Cypress timeout is longer than the oc --timeout - cy.exec( - `oc delete --timeout=2m ${OLS.config.kind} ${OLS.config.name} --kubeconfig ${Cypress.env('KUBECONFIG_PATH')}`, - { failOnNonZeroExit: false, timeout: 3 * MINUTE }, - ); - - // Delete entire namespace to delete operator and ensure everything else is cleaned up - cy.adminCLI(`oc delete namespace ${OLS.namespace}`, { - failOnNonZeroExit: false, - timeout: 5 * MINUTE, - }); - - cy.adminCLI( - `oc adm policy remove-cluster-role-from-user cluster-admin ${Cypress.env('LOGIN_USERNAME')}`, - ); - - // Remove OLS query access permissions - cy.adminCLI( - `oc adm policy remove-cluster-role-from-user lightspeed-operator-query-access ${Cypress.env('LOGIN_USERNAME')}`, - ); - } - }); - - describe('Core functionality', { tags: ['@core'] }, () => { - it('OpenShift Lightspeed popover UI is loaded and basic functionality is working', () => { - // Mock readiness endpoint to ensure the readiness warning is always shown - cy.intercept('GET', '/api/proxy/plugin/lightspeed-console-plugin/ols/readiness', { - statusCode: 200, - body: { ready: false }, - }); - - cy.visit('/'); - - // Wait for the popover to auto-open for first-time users - cy.get(mainButton).should('exist'); - cy.get(popover) - .should('exist') - .should('include.text', FOOTER_TEXT) - .should('include.text', PRIVACY_TEXT) - .should('include.text', READINESS_TITLE) - .should('include.text', READINESS_TEXT) - .should('include.text', WELCOME_TEXT) - .find('h1') - .should('include.text', POPOVER_TITLE); - - // Test that we can submit a prompt - cy.get(promptInput).should('exist').type(`${PROMPT_SUBMITTED}{enter}`); - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED); - cy.get(aiChatEntry).should('exist'); - - // Populate the prompt input, but don't submit it - cy.get(promptInput).type(PROMPT_NOT_SUBMITTED); - - // Minimize the popover UI - cy.get(minimizeButton).click(); - cy.get(popover).should('not.exist'); - - // Open the popover UI again - // Previous messages and text in the prompt input should have been preserved - cy.get(mainButton).click(); - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED); - cy.get(aiChatEntry).should('exist'); - cy.get(promptInput).should('contain', PROMPT_NOT_SUBMITTED); - - // When expanded, the popover width should fill most of the viewport - const isExpanded = (popoverElement) => - Cypress.config('viewportWidth') - popoverElement.getBoundingClientRect().width < 200; - - // When collapsed, the popover width should be less than half the viewport width - const isCollapsed = (popoverElement) => - popoverElement.getBoundingClientRect().width < Cypress.config('viewportWidth') / 2; - - // Expand UI button - cy.get(expandButton).click(); - cy.get(popover) - .should('exist') - .should((els) => { - expect(isExpanded(els[0])).to.be.true; - }) - .should('include.text', FOOTER_TEXT) - .should('include.text', PRIVACY_TEXT) - .should('include.text', READINESS_TITLE) - .should('include.text', READINESS_TEXT); - - // Minimize the popover UI - cy.get(minimizeButton).click(); - cy.get(popover).should('not.exist'); - - // Reopen the UI by clicking the main OLS button - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - // Main OLS button should toggle between closed and open states and preserve the expanded state - cy.get(mainButton).click(); - cy.get(popover).should('not.exist'); - cy.get(mainButton).click(); - cy.get(popover) - .should('exist') - .should((els) => { - expect(isExpanded(els[0])).to.be.true; - }); - - // Collapse UI button - cy.get(collapseButton).click(); - cy.get(popover) - .should('exist') - .should((els) => { - expect(isCollapsed(els[0])).to.be.true; - }); - - // Main OLS button should toggle between closed and open states and preserve the collapsed state - cy.get(mainButton).click(); - cy.get(popover).should('not.exist'); - cy.get(mainButton).click(); - cy.get(popover) - .should('exist') - .should((els) => { - expect(isCollapsed(els[0])).to.be.true; - }); - - // Previous messages and text in the prompt input should have been preserved - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED); - cy.get(aiChatEntry).should('exist'); - cy.get(promptInput).should('contain', PROMPT_NOT_SUBMITTED); - }); - - it('Test Troubleshooting mode persists after reopening the UI', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - cy.get(modeToggle).should('include.text', 'Ask'); - cy.get(modeToggle).click(); - cy.contains('[role="option"]', 'Troubleshooting').click(); - cy.get(modeToggle).should('include.text', 'Troubleshooting'); - - cy.get(minimizeButton).click(); - cy.get(popover).should('not.exist'); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - cy.get(modeToggle).should('include.text', 'Troubleshooting'); - - cy.get(modeToggle).click(); - cy.contains('[role="option"]', 'Ask').click(); - cy.get(modeToggle).should('include.text', 'Ask'); - - cy.get(minimizeButton).click(); - cy.get(popover).should('not.exist'); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - cy.get(modeToggle).should('include.text', 'Ask'); - }); - }); - - describe('Streamed response', { tags: ['@response'] }, () => { - it('Test submitting a prompt and fetching the streamed response', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - cy.interceptQuery('queryStub', PROMPT_SUBMITTED); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.get(loadingIndicator).should('exist'); - cy.wait('@queryStub'); - - // Prompt should now be empty - cy.get(promptInput).should('have.value', ''); - - // Our prompt should now be shown in the chat history along with a response from OLS - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED); - cy.get(aiChatEntry).should('exist').should('contain', MOCK_STREAMED_RESPONSE_TEXT); - - // Sending a second prompt should now send the conversation_id along with the prompt - const PROMPT_SUBMITTED_2 = 'Test prompt 2'; - cy.interceptQuery('queryWithConversationIdStub', PROMPT_SUBMITTED_2, CONVERSATION_ID); - cy.get(promptInput).type(`${PROMPT_SUBMITTED_2}{enter}`); - cy.get(loadingIndicator).should('exist'); - cy.wait('@queryWithConversationIdStub'); - - cy.get(promptInput).should('have.value', ''); - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED_2); - cy.get(aiChatEntry).should('exist').should('contain', MOCK_STREAMED_RESPONSE_TEXT); - - // The clear chat action should clear the current conversation, but leave any text in the prompt - cy.get(promptInput).type(PROMPT_NOT_SUBMITTED); - cy.get(clearChatButton).should('exist').click(); - cy.get(modal).should('exist').should('contain', CLEAR_CHAT_TEXT); - cy.get(modal).find('button').contains(CLEAR_CHAT_CONFIRM_BUTTON).click(); - cy.get(userChatEntry).should('not.exist'); - cy.get(aiChatEntry).should('not.exist'); - cy.get(popover) - .should('include.text', FOOTER_TEXT) - .should('include.text', PRIVACY_TEXT) - .find('h1') - .should('include.text', POPOVER_TITLE); - cy.get(promptInput).should('have.value', PROMPT_NOT_SUBMITTED); - }); - - it('Test response with error, partial response text and tool call', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - cy.interceptQueryWithError('queryWithErrorStub', PROMPT_SUBMITTED, MOCK_ERROR_MESSAGE); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.get(loadingIndicator).should('exist'); - cy.wait('@queryWithErrorStub'); - - cy.get(aiChatEntry).should('exist').should('contain', MOCK_PARTIAL_RESPONSE_TEXT); - cy.get(aiChatEntry) - .find('.pf-m-danger') - .should('exist') - .should('contain', MOCK_ERROR_MESSAGE); - - // Verify that the tool call label is displayed - cy.get(aiChatEntry).find('.pf-v6-c-label').should('exist').should('contain', 'ABC'); - }); - }); - - describe('Tool approval (HITL)', { tags: ['@hitl'] }, () => { - it('Test approval card is shown and tool can be approved', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - cy.interceptQueryWithApproval('queryWithApproval', PROMPT_SUBMITTED); - cy.interceptToolApproval('approvalStub', true); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.wait('@queryWithApproval'); - - cy.get(toolApprovalCard).should('exist'); - cy.get(toolApprovalCard).should('contain', 'Review required'); - cy.get(toolApprovalCard).should('contain', 'This action will list pods in the cluster.'); - cy.get(toolApprovalCard).find('button').contains('Approve').should('exist'); - cy.get(toolApprovalCard).find('button').contains('Reject').should('exist'); - - cy.get(toolApprovalCard).contains('View action details').click(); - cy.get(toolApprovalCard).should('contain', 'mock_tool'); - cy.get(toolApprovalCard).should('contain', 'namespace'); - - cy.get(toolApprovalCard).find('button').contains('Approve').click(); - cy.wait('@approvalStub'); - cy.get(toolApprovalCard).should('not.exist'); - cy.get(toolLabel).should('contain', 'mock_tool'); - - cy.get(toolLabel).contains('mock_tool').click(); - cy.get(modal).should('contain', 'Tool output'); - cy.get(modal).should('contain', 'mock_tool'); - cy.get(modal).should('contain', 'Status'); - cy.get(modal).should('contain', 'pending'); - cy.get(modal).should('not.contain', 'Tool call rejected'); - cy.get(modal).find('button[title="Close"]').click(); - }); - - it('Test tool can be rejected', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - cy.interceptQueryWithApproval('queryWithApproval', PROMPT_SUBMITTED); - cy.interceptToolApproval('denialStub', false); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.wait('@queryWithApproval'); - - cy.get(toolApprovalCard).should('exist'); - cy.get(toolApprovalCard).find('button').contains('Reject').click(); - cy.wait('@denialStub'); - cy.get(toolApprovalCard).should('not.exist'); - cy.get(toolLabel).should('contain', 'mock_tool'); - cy.get(toolLabel).contains('mock_tool').click(); - cy.get(modal).should('contain', 'Tool call rejected'); - cy.get(modal).should('contain', 'mock_tool'); - cy.get(modal).should('not.contain', 'Status'); - cy.get(modal).should('not.contain', 'Content'); - cy.get(modal).find('button[title="Close"]').click(); - }); - }); - - describe('User feedback', { tags: ['@feedback'] }, () => { - it('Test user feedback form', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - cy.interceptQuery('queryStub', PROMPT_SUBMITTED); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.get(loadingIndicator).should('exist'); - cy.wait('@queryStub'); - - // Should have 3 response action buttons (thumbs up, thumbs down, and copy) - cy.get(responseAction).should('have.lengthOf', 3); - - // Submit positive feedback with a comment - cy.get(responseAction).eq(0).click(); - cy.get(popover).should('contain', USER_FEEDBACK_TEXT); - cy.interceptFeedback( - 'userFeedbackStub', - CONVERSATION_ID, - THUMBS_UP, - USER_FEEDBACK_SUBMITTED, - `${PROMPT_SUBMITTED}\n---\nThe attachments that were sent with the prompt are shown below.\n[]`, - ); - cy.get(userFeedbackInput).type(USER_FEEDBACK_SUBMITTED); - cy.get(userFeedbackSubmit).click(); - cy.wait('@userFeedbackStub'); - cy.get(popover).should('contain', USER_FEEDBACK_RECEIVED_TEXT); - - // Submit negative feedback with no comment - cy.get(responseAction).eq(1).click(); - cy.get(popover).should('contain', USER_FEEDBACK_TEXT); - cy.interceptFeedback( - 'userFeedbackWithoutCommentStub', - CONVERSATION_ID, - THUMBS_DOWN, - '', - `${PROMPT_SUBMITTED}\n---\nThe attachments that were sent with the prompt are shown below.\n[]`, - ); - cy.get(userFeedbackInput).clear(); - cy.get(userFeedbackSubmit).click(); - cy.wait('@userFeedbackWithoutCommentStub'); - cy.get(popover).should('contain', USER_FEEDBACK_RECEIVED_TEXT); - }); - }); - - describe('Copy to clipboard', { tags: ['@clipboard'] }, () => { - it('Test copy response functionality', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - cy.interceptQuery('queryStub', PROMPT_SUBMITTED); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.get(loadingIndicator).should('exist'); - cy.wait('@queryStub'); - - cy.get(copyResponseButton).should('exist'); - cy.window().focus(); - cy.get(copyResponseButton).click(); - - // Try to read from actual clipboard to verify copy worked - cy.window().then((win) => { - if (win.navigator.clipboard && win.navigator.clipboard.readText) { - return win.navigator.clipboard - .readText() - .then((text) => { - expect(text).to.equal(MOCK_STREAMED_RESPONSE_TEXT); - }) - .catch((err) => { - cy.log('Clipboard access denied, skipping clipboard test:', err.message); - }); - } - }); - }); - - it('Test copy conversation functionality', () => { - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - - // Submit first prompt and wait for response - cy.interceptQuery('queryStub1', PROMPT_SUBMITTED); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.wait('@queryStub1'); - - // Submit second prompt to create a conversation - const PROMPT_SUBMITTED_2 = 'Second test prompt'; - cy.interceptQuery('queryStub2', PROMPT_SUBMITTED_2, CONVERSATION_ID); - cy.get(promptInput).type(`${PROMPT_SUBMITTED_2}{enter}`); - cy.wait('@queryStub2'); - - // Verify both messages are in the chat history - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED); - cy.get(userChatEntry).should('contain', PROMPT_SUBMITTED_2); - cy.get(aiChatEntry).should('have.length', 2); - - cy.get(copyConversationButton).should('exist').trigger('mouseenter'); - cy.get(copyConversationTooltip) - .should('be.visible') - .should('contain.text', 'Copy conversation'); - - cy.window().focus(); - cy.get(copyConversationButton).click(); - - // Tooltip text should change, then revert back after a timeout - cy.get(copyConversationTooltip).should('be.visible').should('contain.text', 'Copied'); - cy.get(copyConversationTooltip, { timeout: 3000 }).should( - 'contain.text', - 'Copy conversation', - ); - - // Copy conversation button should not exist when there is no chat history - cy.get(clearChatButton).click(); - cy.get(modal).find('button').contains('Erase and start new chat').click(); - cy.get(copyConversationButton).should('not.exist'); - }); - }); - - describe('Attach menu', { tags: ['@attach'] }, () => { - it('Test attach options on pods list page', () => { - pages.goToPodsList('openshift-console'); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - // Confirm that the pod we are using for testing is present - listPage.filter.byName(podNamePrefix); - cy.get('[data-test-rows="resource-row"]', { timeout: 2 * MINUTE }).should( - 'have.length.at.least', - 1, - ); - - // The only attach option should be the upload file option - cy.get(attachButton).click(); - cy.get(attachMenu) - .should('include.text', 'Upload from computer') - .should('not.include.text', 'YAML') - .should('not.include.text', 'Events') - .should('not.include.text', 'Logs'); - }); - - it('Test attaching YAML', () => { - pages.goToPodDetails('openshift-console', podNamePrefix); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - // There should be no prompt attachments initially - cy.get(attachments).should('be.empty'); - - cy.get(attachButton).click(); - cy.get(attachMenu) - .should('include.text', 'Full YAML file') - .should('include.text', 'Filtered YAML') - .should('include.text', 'Events') - .should('include.text', 'Logs') - .should('include.text', 'Upload from computer'); - - cy.get(attachMenu).find('button').contains('Full YAML file').click(); - cy.get(attachments) - .should('include.text', podNamePrefix) - .should('include.text', 'YAML') - .find('button') - .contains(podNamePrefix) - .should('have.lengthOf', 1) - .click(); - cy.get(modal) - .should('include.text', 'Preview attachment') - .should('include.text', podNamePrefix) - .should('include.text', 'kind: Pod') - .should('include.text', 'apiVersion: v1') - .find('button') - .contains('Dismiss') - .click(); - cy.get(promptInput).type('Test{enter}'); - - cy.get(attachButton).click(); - cy.get(attachMenu).find('button').contains('Filtered YAML').click(); - cy.get(attachments) - .should('include.text', podNamePrefix) - .should('include.text', 'YAML') - .find('button') - .contains(podNamePrefix) - .should('have.lengthOf', 1) - .click(); - cy.get(modal) - .should('include.text', 'Preview attachment') - .should('include.text', podNamePrefix) - .should('include.text', 'kind: Pod') - .should('not.contain', 'apiVersion: v1') - .find('button') - .contains('Dismiss') - .click(); - cy.get(promptInput).type('Test{enter}'); - }); - - it('Test modifying attached YAML (OLS-1541)', () => { - pages.goToPodDetails('openshift-console', podNamePrefix); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - cy.get(attachButton).click(); - cy.get(attachMenu).find('button').contains('Full YAML file').click(); - cy.get(promptAttachment).click(); - cy.get(modal).find('button').contains('Dismiss').click(); - cy.get(promptAttachment).click(); - cy.get(modal).find('button').contains('Edit').click(); - cy.get(modal).find('button').contains('Cancel').click(); - cy.get(modal).find('button').contains('Edit').click(); - cy.get(modal) - .find('.ols-plugin__code-block__title') - .should('be.visible') - .and('contain.text', podNamePrefix); - cy.get(modal).find('.monaco-editor').should('be.visible').and('contain.text', podNamePrefix); - setEditorContent('Test modifying YAML'); - cy.get(modal).find('button').contains('Save').click(); - cy.get(promptAttachment).click(); - cy.get(modal) - .find('.ols-plugin__code-block-code') - .should('be.visible') - .and('contain.text', 'Test modifying YAML'); - }); - - it('Test attaching events', () => { - pages.goToPodDetails('openshift-lightspeed', podNamePrefix); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - cy.get(attachButton).click(); - cy.get(attachMenu).find('button').contains('Events').click(); - cy.get(modal).should('include.text', 'Configure events attachment'); - cy.get(modal).find('button').contains('Attach').click(); - cy.get(attachments) - .should('include.text', podNamePrefix) - .should('include.text', 'Events') - .find('button') - .contains(podNamePrefix) - .should('have.lengthOf', 1) - .click(); - cy.get(modal) - .should('include.text', 'Preview attachment') - .should('include.text', podNamePrefix) - .should('include.text', 'kind: Event') - .find('button') - .contains('Dismiss') - .click(); - cy.interceptQuery( - 'queryStub', - PROMPT_SUBMITTED, - null, - // eslint-disable-next-line camelcase - [{ attachment_type: 'event', content_type: 'application/yaml' }], - ); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.wait('@queryStub'); - - // Submitting user feedback should now include the attachment information - cy.interceptFeedback( - 'userFeedbackWithAttachmentStub', - CONVERSATION_ID, - THUMBS_UP, - USER_FEEDBACK_SUBMITTED, - `${PROMPT_SUBMITTED}\n---\nThe attachments that were sent with the prompt are shown below.\n[\n {\n "attachment_type": "event",\n "content": "- kind: Event`, - ); - - cy.get(responseAction).eq(0).click(); - cy.get(userFeedbackInput).type(USER_FEEDBACK_SUBMITTED); - cy.get(userFeedbackSubmit).click(); - cy.wait('@userFeedbackWithAttachmentStub'); - cy.get(popover).should('contain', USER_FEEDBACK_RECEIVED_TEXT); - }); - - it('Test attaching logs', () => { - pages.goToPodDetails('openshift-console', podNamePrefix); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - cy.get(attachButton).click(); - cy.get(attachMenu).find('button').contains('Logs').click(); - cy.get(modal) - .should('include.text', 'Configure log attachment') - .should('include.text', 'Most recent 25 lines') - .find('button') - .contains('Attach') - .click(); - cy.get(attachments) - .should('include.text', podNamePrefix) - .should('include.text', 'Log') - .find('button') - .contains(podNamePrefix) - .should('have.lengthOf', 1) - .click(); - cy.get(modal) - .should('include.text', 'Preview attachment') - .should('include.text', podNamePrefix) - .should('include.text', 'Most recent lines from the log for') - .find('button') - .contains('Dismiss') - .click(); - cy.interceptQuery( - 'queryStub', - PROMPT_SUBMITTED, - null, - // eslint-disable-next-line camelcase - [{ attachment_type: 'log', content_type: 'text/plain' }], - ); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.wait('@queryStub'); - }); - - it('Test file upload', () => { - const MAX_FILE_SIZE_MB = 1; - - cy.visit('/search/all-namespaces'); - cy.get('h1').contains('Search').should('exist'); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - cy.get(attachButton).click(); - cy.get(attachMenu).find('button').contains('Upload from computer').click(); - - // File with invalid YAML - cy.get(fileInput).selectFile( - { - contents: Cypress.Buffer.from('abc'), - }, - // Use `force: true` because the input is display:none - { force: true }, - ); - cy.get(popover).should('contain', 'Uploaded file is not valid YAML'); - - // File that is too large - const largeFileContent = 'a'.repeat(MAX_FILE_SIZE_MB * 1024 * 1024 + 1); - cy.get(fileInput).selectFile( - { - contents: Cypress.Buffer.from(largeFileContent), - }, - { force: true }, - ); - cy.get(popover).should( - 'contain', - `Uploaded file is too large. Max size is ${MAX_FILE_SIZE_MB} MB.`, - ); - - // Valid YAML Upload - cy.get(fileInput).selectFile( - { - contents: Cypress.Buffer.from(` -kind: Pod -metadata: - name: my-test-pod - namespace: test-namespace -`), - }, - { force: true }, - ); - - // For valid YAML, the error should disappear and an attachment should be added - cy.get(popover).should('not.contain', 'Uploaded file is not valid YAML'); - cy.get(attachments).should('contain', 'my-test-pod'); - }); - }); - - describe('ACM', { tags: ['@acm'] }, () => { - it.skip('Test attach cluster info for ManagedCluster', () => { - cy.visit( - '/k8s/ns/test-cluster/cluster.open-cluster-management.io~v1~ManagedCluster/test-cluster', - ); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - // Test that the attach menu shows the option for ManagedCluster - cy.get(attachButton).click(); - cy.get(attachMenu) - .should('include.text', ACM_ATTACH_CLUSTER_TEXT) - .should('include.text', 'Upload from computer') - .should('not.include.text', 'Full YAML file') - .should('not.include.text', 'Filtered YAML') - .should('not.include.text', 'Events') - .should('not.include.text', 'Logs'); - - // Mock the API call for ManagedCluster - cy.intercept( - 'GET', - '/api/kubernetes/apis/cluster.open-cluster-management.io/v1/managedclusters/test-cluster', - { - statusCode: 200, - body: { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'test-cluster', - namespace: 'test-cluster', - }, - spec: { - hubAcceptsClient: true, - }, - status: { - conditions: [ - { - type: 'ManagedClusterConditionAvailable', - status: 'True', - }, - ], - }, - }, - }, - ).as('getManagedCluster'); - - // Mock the API call for ManagedClusterInfo - cy.intercept( - 'GET', - '/api/kubernetes/apis/internal.open-cluster-management.io/v1beta1/namespaces/test-cluster/managedclusterinfos/test-cluster', - { - statusCode: 200, - body: { - kind: 'ManagedClusterInfo', - apiVersion: 'internal.open-cluster-management.io/v1beta1', - metadata: { - name: 'test-cluster', - namespace: 'test-cluster', - }, - status: { - distributionInfo: { - type: 'OCP', - ocp: { - version: '4.14.0', - }, - }, - nodeList: [ - { - name: 'master-0', - conditions: [ - { - type: 'Ready', - status: 'True', - }, - ], - }, - ], - }, - }, - }, - ).as('getManagedClusterInfo'); - - cy.get(attachMenu).find('button').contains(ACM_ATTACH_CLUSTER_TEXT).click(); - - // Wait for both API calls - cy.wait('@getManagedCluster'); - cy.wait('@getManagedClusterInfo'); - - // Verify that both ManagedCluster and ManagedClusterInfo attachments are added - cy.get(attachments) - .should('include.text', 'test-cluster') - .should('include.text', 'YAML') - .find('button') - .should('have.length', 2); - - // Test the ManagedCluster attachment preview - cy.get(attachments).find('button').contains('test-cluster').first().click(); - cy.get(modal) - .should('include.text', 'Preview attachment') - .should('include.text', 'test-cluster') - .should('include.text', 'kind: ManagedCluster') - .should('include.text', 'apiVersion: cluster.open-cluster-management.io/v1') - .find('button') - .contains('Dismiss') - .click(); - - // Test the ManagedClusterInfo attachment preview - cy.get(attachments).find('button').contains('test-cluster').last().click(); - cy.get(modal) - .should('include.text', 'Preview attachment') - .should('include.text', 'test-cluster') - .should('include.text', 'kind: ManagedClusterInfo') - .should('include.text', 'apiVersion: internal.open-cluster-management.io/v1beta1') - .should('include.text', 'distributionInfo') - .find('button') - .contains('Dismiss') - .click(); - - // Test submitting a prompt with cluster attachments - cy.interceptQuery('queryStub', PROMPT_SUBMITTED, null, [ - // eslint-disable-next-line camelcase - { attachment_type: 'yaml', content_type: 'application/yaml' }, - // eslint-disable-next-line camelcase - { attachment_type: 'yaml', content_type: 'application/yaml' }, - ]); - cy.get(promptInput).type(`${PROMPT_SUBMITTED}{enter}`); - cy.wait('@queryStub'); - }); - - it.skip('Test ManagedCluster attachment error handling', () => { - cy.visit( - '/k8s/ns/test-cluster/cluster.open-cluster-management.io~v1~ManagedCluster/test-cluster', - ); - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - // Mock successful ManagedCluster API call - cy.intercept( - 'GET', - '/api/kubernetes/apis/cluster.open-cluster-management.io/v1/managedclusters/test-cluster', - { - statusCode: 200, - body: { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'test-cluster', - namespace: 'test-cluster', - }, - }, - }, - ).as('getManagedCluster'); - - // Mock failed ManagedClusterInfo API call - cy.intercept( - 'GET', - '/api/kubernetes/apis/internal.open-cluster-management.io/v1beta1/namespaces/test-cluster/managedclusterinfos/test-cluster', - { - statusCode: 404, - body: { - kind: 'Status', - message: - 'managedclusterinfos.internal.open-cluster-management.io "test-cluster" not found', - }, - }, - ).as('getManagedClusterInfoError'); - - cy.get(attachButton).click(); - cy.get(attachMenu).find('button').contains(ACM_ATTACH_CLUSTER_TEXT).click(); - - // Wait for API calls - cy.wait('@getManagedCluster'); - cy.wait('@getManagedClusterInfoError'); - - // Verify error is displayed - cy.get(attachMenu).should('include.text', 'Error fetching cluster info'); - }); - - it.skip('Test ACM search resources page context for Pod', () => { - cy.visit('/multicloud/search/resources?kind=Pod&name=test-pod&namespace=test-namespace'); - - // Mock successful pod API call - cy.intercept('GET', '/api/kubernetes/api/v1/namespaces/test-namespace/pods/test-pod', { - statusCode: 200, - body: { - kind: 'Pod', - metadata: { - name: 'test-pod', - namespace: 'test-namespace', - }, - }, - }).as('getManagedCluster'); - - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - cy.get(attachButton).click(); - cy.get(attachMenu) - .should('include.text', 'Upload from computer') - .should('include.text', 'Full YAML file') - .should('include.text', 'Filtered YAML') - .should('include.text', 'Events') - .should('include.text', 'Logs') - .should('not.include.text', ACM_ATTACH_CLUSTER_TEXT); - }); - - it.skip('Test ACM search resources page context for VirtualMachine', () => { - cy.visit( - '/multicloud/search/resources?kind=VirtualMachine&name=test-vm&namespace=test-namespace', - ); - - // Mock successful VirtualMachine API call - cy.intercept( - 'GET', - '/api/kubernetes/apis/kubevirt.io/v1/namespaces/test-namespace/virtualmachines/test-vm', - { - statusCode: 200, - body: { - kind: 'VirtualMachine', - apiVersion: 'kubevirt.io/v1', - metadata: { - name: 'test-vm', - namespace: 'test-namespace', - }, - }, - }, - ).as('getVirtualMachine'); - - cy.get(mainButton).click(); - cy.get(popover).should('exist'); - - cy.get(attachButton).click(); - cy.get(attachMenu) - .should('include.text', 'Upload from computer') - .should('include.text', 'Full YAML file') - .should('include.text', 'Filtered YAML') - .should('include.text', 'Events') - .should('include.text', 'Logs') - .should('not.include.text', ACM_ATTACH_CLUSTER_TEXT); - }); - }); - - describe('MCP Iframe Rendering', { tags: ['@mcp', '@mcp-mocked', '@iframe'] }, () => { - const mcpAppIframe = '.ols-plugin__mcp-app-iframe'; - const mcpAppCard = '.ols-plugin__mcp-app'; - const mcpAppLoading = `${mcpAppCard} .pf-v6-c-spinner`; - const mcpAppError = '.ols-plugin__alert'; - - const MCP_PROMPT = 'Show me the dashboard'; - const MCP_TOOL_NAME = 'dashboard'; - const MCP_UI_RESOURCE_URI = 'mcp://test-server/resources/dashboard'; - - const SAMPLE_MCP_HTML = ` - - -

MCP Dashboard

-

Resource Dashboard

-

CPU Usage

-

45%

- -`; - - beforeEach(() => { - // Mock readiness endpoint to prevent polling delays - cy.intercept('GET', '/api/proxy/plugin/lightspeed-console-plugin/ols/readiness', { - statusCode: 200, - body: { ready: true }, - }); - - // Mock authorization endpoint for clean test runs - cy.intercept('POST', '/api/proxy/plugin/lightspeed-console-plugin/ols/authorized', { - statusCode: 200, - /* eslint-disable camelcase */ - body: { user_id: 'test-user-id', username: 'test-user', skip_user_id_check: false }, - /* eslint-enable camelcase */ - }); - - cy.visit('/'); - cy.get(mainButton).click(); - cy.get(popover).should('be.visible'); - // Wait for authorization to complete and prompt to be ready - cy.get(promptInput, { timeout: 10000 }).should('be.visible').should('be.enabled'); - }); - - it('renders iframe when MCP response includes uiResourceUri', { tags: ['@core'] }, () => { - cy.interceptMCPQuery('mcpQuery', MCP_PROMPT, MCP_TOOL_NAME, MCP_UI_RESOURCE_URI); - cy.interceptMCPResources('mcpResources', SAMPLE_MCP_HTML, 'test-server', MCP_UI_RESOURCE_URI); - - cy.get(promptInput).type(`${MCP_PROMPT}{enter}`); - - cy.wait('@mcpQuery', { timeout: 3 * MINUTE }); - cy.wait('@mcpResources', { timeout: 3 * MINUTE }); - - cy.get(mcpAppIframe, { timeout: 10000 }) - .should('exist') - .scrollIntoView() - .should('be.visible'); - cy.get(mcpAppIframe).should('have.attr', 'sandbox', 'allow-scripts'); - cy.get(mcpAppCard).should('exist'); - }); - - it('iframe srcDoc contains expected HTML content', { tags: ['@core'] }, () => { - cy.interceptMCPQuery('mcpQuery', MCP_PROMPT, MCP_TOOL_NAME, MCP_UI_RESOURCE_URI); - cy.interceptMCPResources('mcpResources', SAMPLE_MCP_HTML, 'test-server', MCP_UI_RESOURCE_URI); - - cy.get(promptInput).type(`${MCP_PROMPT}{enter}`); - - cy.wait('@mcpQuery', { timeout: 3 * MINUTE }); - cy.wait('@mcpResources', { timeout: 3 * MINUTE }); - - cy.get(mcpAppIframe, { timeout: 10000 }).should(($iframe) => { - const srcDoc = $iframe.attr('srcDoc'); - expect(srcDoc).to.exist; - expect(srcDoc).to.contain('MCP Dashboard'); - expect(srcDoc).to.contain('Resource Dashboard'); - expect(srcDoc).to.contain('CPU Usage'); - expect(srcDoc).to.contain('45%'); - expect(srcDoc).to.contain('data-theme='); - }); - }); - - it('displays loading state while fetching MCP resources', () => { - cy.interceptMCPQuery('mcpQuery', MCP_PROMPT, MCP_TOOL_NAME, MCP_UI_RESOURCE_URI); - cy.intercept('POST', '**/v1/mcp-apps/resources', (request) => { - request.reply({ body: { content: SAMPLE_MCP_HTML }, delay: 2000 }); - }).as('mcpResourcesDelayed'); - - cy.get(promptInput).type(`${MCP_PROMPT}{enter}`); - - cy.wait('@mcpQuery', { timeout: 3 * MINUTE }); - - cy.get(mcpAppLoading, { timeout: 5000 }).should('exist'); - - cy.wait('@mcpResourcesDelayed', { timeout: 5000 }); - - cy.get(mcpAppLoading).should('not.exist'); - cy.get(mcpAppIframe).should('exist').scrollIntoView().should('be.visible'); - }); - - it('displays error when resource fetch fails', () => { - cy.interceptMCPQuery('mcpQuery', MCP_PROMPT, MCP_TOOL_NAME, MCP_UI_RESOURCE_URI); - - cy.intercept('POST', '**/v1/mcp-apps/resources', { - statusCode: 500, - body: { error: 'Failed to fetch MCP resource' }, - }).as('mcpResourcesError'); - - cy.get(promptInput).type(`${MCP_PROMPT}{enter}`); - - cy.wait('@mcpQuery', { timeout: 3 * MINUTE }); - cy.wait('@mcpResourcesError', { timeout: 3 * MINUTE }); - - cy.get(mcpAppError, { timeout: 10000 }).should('exist').should('contain', 'MCP App Error'); - cy.get(mcpAppIframe).should('not.exist'); - }); - - it('does not render iframe when uiResourceUri is missing', () => { - const responseWithoutURI = `data: {"event": "start", "data": {"conversation_id": "${CONVERSATION_ID}"}} - -data: {"event": "token", "data": {"id": 0, "token": "Here"}} - -data: {"event": "token", "data": {"id": 1, "token": " is"}} - -data: {"event": "token", "data": {"id": 2, "token": " your"}} - -data: {"event": "token", "data": {"id": 3, "token": " data"}} - -data: {"event": "tool_call", "data": {"id": 1, "name": "get_data", "server_name": "test-server", "args": {}}} - -data: {"event": "tool_result", "data": {"id": 1, "content": "Data retrieved", "status": "success"}} - -data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} -`; - - cy.intercept('POST', '**/v1/streaming_query', (request) => { - expect(request.body.query).to.equal(MCP_PROMPT); - request.reply({ body: responseWithoutURI, delay: 1000 }); - }).as('queryWithoutURI'); - - cy.get(promptInput).type(`${MCP_PROMPT}{enter}`); - - cy.wait('@queryWithoutURI', { timeout: 3 * MINUTE }); - - cy.get(aiChatEntry, { timeout: 10000 }).should('exist'); - cy.get(mcpAppIframe).should('not.exist'); - }); - - it('handles multiple MCP iframes in conversation', () => { - const SECOND_PROMPT = 'Show me another dashboard'; - const SECOND_TOOL_NAME = 'metrics'; - const SECOND_URI = 'mcp://test-server/resources/metrics'; - - const SECOND_HTML = ` - -Metrics -
Metrics Dashboard
-`; - - cy.interceptMCPQuery('mcpQuery1', MCP_PROMPT, MCP_TOOL_NAME, MCP_UI_RESOURCE_URI); - cy.interceptMCPResources( - 'mcpResources1', - SAMPLE_MCP_HTML, - 'test-server', - MCP_UI_RESOURCE_URI, - ); - - cy.get(promptInput).type(`${MCP_PROMPT}{enter}`); - cy.wait('@mcpQuery1', { timeout: 3 * MINUTE }); - cy.wait('@mcpResources1', { timeout: 3 * MINUTE }); - - cy.get(mcpAppIframe).should('have.length', 1); - - cy.interceptMCPQuery( - 'mcpQuery2', - SECOND_PROMPT, - SECOND_TOOL_NAME, - SECOND_URI, - CONVERSATION_ID, - ); - cy.interceptMCPResources('mcpResources2', SECOND_HTML, 'test-server', SECOND_URI); - - cy.get(promptInput).type(`${SECOND_PROMPT}{enter}`); - cy.wait('@mcpQuery2', { timeout: 3 * MINUTE }); - cy.wait('@mcpResources2', { timeout: 3 * MINUTE }); - - cy.get(mcpAppIframe).should('have.length', 2); - }); - }); -}); diff --git a/tests/tests/lightspeed.spec.ts b/tests/tests/lightspeed.spec.ts new file mode 100644 index 00000000..8f7cb32a --- /dev/null +++ b/tests/tests/lightspeed.spec.ts @@ -0,0 +1,1187 @@ +import type { Page } from '@playwright/test'; + +import { + CONVERSATION_ID, + expect, + interceptFeedback, + interceptQuery, + MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE, + MOCK_STREAMED_RESPONSE_BODY, + MOCK_STREAMED_RESPONSE_WITH_APPROVAL_BODY, + MOCK_STREAMED_RESPONSE_WITH_ERROR_BODY, + test, +} from '../support/fixtures'; + +const resourceRows = '.co-resource-item__resource-name'; + +const setEditorContent = (page: Page, text: string) => + page.evaluate( + (t) => (window as any).monaco.editor.getModels()[0].setValue(t), // eslint-disable-line @typescript-eslint/no-explicit-any + text, + ); + +const filterByName = async (page: Page, name: string) => { + const input = page.getByPlaceholder('Filter by name'); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill(name); +}; + +const goToPodsList = async (page: Page, ns: string | null = null) => { + await page.goto(ns ? `/k8s/ns/${ns}/pods` : '/k8s/all-namespaces/pods'); + await expect(page.locator(resourceRows).first()).toBeVisible(); +}; + +const goToPodDetails = async (page: Page, ns: string, podName: string) => { + await goToPodsList(page, ns); + await filterByName(page, podName); + const rows = page.locator(resourceRows); + await expect(async () => { + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(1); + expect(count).toBeLessThanOrEqual(4); + }).toPass({ timeout: 5_000 }); + await page.locator(resourceRows).first().click(); +}; + +const popover = '[data-test="ols-plugin__popover"]'; +const mainButton = '[data-test="ols-plugin__popover-button"]'; +const minimizeButton = '[data-test="ols-plugin__popover-minimize-button"]'; +const expandButton = '[data-test="ols-plugin__popover-expand-button"]'; +const collapseButton = '[data-test="ols-plugin__popover-collapse-button"]'; +const clearChatButton = '[data-test="ols-plugin__clear-chat-button"]'; +const userChatEntry = '[data-test="ols-plugin__chat-entry-user"]'; +const aiChatEntry = '[data-test="ols-plugin__chat-entry-ai"]'; +const loadingIndicator = `${popover} .pf-chatbot__message-loading`; +const attachments = `${popover} .ols-plugin__prompt-attachments`; +const attachMenu = '.pf-chatbot__menu'; +const promptAttachment = `${attachments} .ols-plugin__context-label`; +const fileInput = '[data-test="ols-plugin__file-upload"]'; +const responseAction = `${popover} .pf-chatbot__button--response-action`; +const copyConversationButton = '[data-test="ols-plugin__copy-conversation-button"]'; +const copyConversationTooltip = '[data-test="ols-plugin__copy-conversation-tooltip"]'; +const copyResponseButton = `${responseAction}[aria-label=Copy]`; +const userFeedbackSel = `${popover} .ols-plugin__feedback`; +const userFeedbackInput = `${userFeedbackSel} textarea`; +const userFeedbackSubmit = `${userFeedbackSel} button.pf-m-primary`; +const modal = '.pf-v6-c-modal-box'; +const attachmentModal = '.ols-plugin__attachment-modal'; +const toolApprovalCard = `${popover} .ols-plugin__tool-call`; +const toolLabel = `${popover} .pf-v6-c-label`; + +const promptArea = `${popover} .ols-plugin__prompt`; +const attachButton = `${promptArea} .pf-chatbot__button--attach`; +const promptInput = `${promptArea} textarea`; +const modeToggle = `${popover} [data-test="ols-plugin__mode-toggle"]`; + +const openPopover = async (page: Page) => { + const btn = page.locator(mainButton); + const pop = page.locator(popover); + await expect(btn).toBeVisible(); + + await expect(async () => { + if (!(await pop.isVisible())) { + await btn.click(); + } + await expect(pop).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 10_000 }); +}; + +const podNamePrefix = 'console'; + +const MINUTE = 60 * 1000; + +const PROMPT_SUBMITTED = 'What is OpenShift?'; +const PROMPT_NOT_SUBMITTED = 'Test prompt that should not be submitted'; +const USER_FEEDBACK_SUBMITTED = 'Good answer!\nMultiple lines\n\n(@#$%^&*) 😀 文字'; + +const POPOVER_TITLE = 'Red Hat OpenShift Lightspeed'; +const FOOTER_TEXT = 'Always review AI generated content prior to use.'; +const PRIVACY_TEXT = + "OpenShift Lightspeed uses AI technology to help answer your questions. Do not include personal information or other sensitive information in your input. Interactions may be used to improve Red Hat's products or services."; +const WELCOME_TEXT = 'Welcome to OpenShift Lightspeed'; + +const CLEAR_CHAT_TEXT = + 'Are you sure you want to erase the current chat conversation and start a new chat? This action cannot be undone.'; +const CLEAR_CHAT_CONFIRM_BUTTON = 'Erase and start new chat'; + +const READINESS_TITLE = 'Waiting for OpenShift Lightspeed service'; +const READINESS_TEXT = + 'The OpenShift Lightspeed service is not yet ready to receive requests. If this message persists, please check the OLSConfig.'; + +const ACM_ATTACH_CLUSTER_TEXT = 'Attach cluster info'; + +const USER_FEEDBACK_TEXT = + "Do not include personal information or other sensitive information in your feedback. Feedback may be used to improve Red Hat's products or services."; +const USER_FEEDBACK_RECEIVED_TEXT = 'Feedback submitted'; +const THUMBS_DOWN = -1; +const THUMBS_UP = 1; + +const MOCK_STREAMED_RESPONSE_TEXT = 'Mock OLS response'; +const MOCK_PARTIAL_RESPONSE_TEXT = 'Partial response'; +const MOCK_ERROR_MESSAGE = 'Service temporarily unavailable'; + +test.describe('OLS UI', () => { + test.describe.serial('Core functionality', { tag: ['@core'] }, () => { + test('OpenShift Lightspeed popover UI is loaded and basic functionality is working', async ({ + page, + }) => { + await page.route('**/api/proxy/plugin/lightspeed-console-plugin/ols/readiness', (route) => + route.fulfill({ status: 200, json: { ready: false } }), + ); + + await page.goto('/'); + + await expect(page.locator(mainButton)).toBeVisible(); + const pop = page.locator(popover); + await expect(pop).toContainText(FOOTER_TEXT); + await expect(pop).toContainText(PRIVACY_TEXT); + await expect(pop).toContainText(READINESS_TITLE); + await expect(pop).toContainText(READINESS_TEXT); + await expect(pop).toContainText(WELCOME_TEXT); + await expect(pop.locator('h1')).toContainText(POPOVER_TITLE); + + await expect(page.locator(promptInput)).toBeVisible(); + await expect(page.locator(promptInput)).toBeEnabled(); + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + await expect(page.locator(userChatEntry)).toContainText(PROMPT_SUBMITTED); + await expect(page.locator(aiChatEntry)).toBeVisible(); + + await page.locator(promptInput).fill(PROMPT_NOT_SUBMITTED); + + await page.locator(minimizeButton).click(); + await expect(page.locator(popover)).toBeHidden(); + + await page.locator(mainButton).click(); + await expect(page.locator(userChatEntry)).toContainText(PROMPT_SUBMITTED); + await expect(page.locator(aiChatEntry)).toBeVisible(); + await expect(page.locator(promptInput)).toHaveValue(PROMPT_NOT_SUBMITTED); + + const { width: viewportWidth } = page.viewportSize()!; + + await page.locator(expandButton).click(); + await expect(pop).toBeVisible(); + const expandedBox = await pop.boundingBox(); + expect(viewportWidth - expandedBox!.width).toBeLessThan(250); + await expect(pop).toContainText(FOOTER_TEXT); + await expect(pop).toContainText(PRIVACY_TEXT); + await expect(pop).toContainText(READINESS_TITLE); + await expect(pop).toContainText(READINESS_TEXT); + + await page.locator(minimizeButton).click(); + await expect(pop).toBeHidden(); + + await page.locator(mainButton).click(); + await expect(pop).toBeVisible(); + + await page.locator(mainButton).click(); + await expect(pop).toBeHidden(); + await page.locator(mainButton).click(); + await expect(pop).toBeVisible(); + const expandedBox2 = await pop.boundingBox(); + expect(viewportWidth - expandedBox2!.width).toBeLessThan(250); + + await page.locator(collapseButton).click(); + await expect(pop).toBeVisible(); + const collapsedBox = await pop.boundingBox(); + expect(collapsedBox!.width).toBeLessThan(viewportWidth / 2); + + await page.locator(mainButton).click(); + await expect(pop).toBeHidden(); + await page.locator(mainButton).click(); + await expect(pop).toBeVisible(); + const collapsedBox2 = await pop.boundingBox(); + expect(collapsedBox2!.width).toBeLessThan(viewportWidth / 2); + + await expect(page.locator(userChatEntry)).toContainText(PROMPT_SUBMITTED); + await expect(page.locator(aiChatEntry)).toBeVisible(); + await expect(page.locator(promptInput)).toHaveValue(PROMPT_NOT_SUBMITTED); + }); + + test('Test Troubleshooting mode persists after reopening the UI', async ({ page }) => { + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await expect(page.locator(mainButton)).toBeVisible(); + await openPopover(page); + + await expect(page.locator(modeToggle)).toContainText('Ask'); + await page.locator(modeToggle).click(); + await page.locator('[role="option"]').filter({ hasText: 'Troubleshooting' }).click(); + await expect(page.locator(modeToggle)).toContainText('Troubleshooting'); + + await page.locator(minimizeButton).click(); + await expect(page.locator(popover)).toBeHidden(); + await openPopover(page); + await expect(page.locator(modeToggle)).toContainText('Troubleshooting'); + + await page.locator(modeToggle).click(); + await page.locator('[role="option"]').filter({ hasText: 'Ask' }).click(); + await expect(page.locator(modeToggle)).toContainText('Ask'); + + await page.locator(minimizeButton).click(); + await expect(page.locator(popover)).toBeHidden(); + await openPopover(page); + await expect(page.locator(modeToggle)).toContainText('Ask'); + }); + }); + + test.describe.serial('Streamed response', { tag: ['@response'] }, () => { + test('Test submitting a prompt and fetching the streamed response', async ({ page }) => { + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + await page.route(`**/v1/streaming_query`, async (route) => { + await new Promise((r) => { + setTimeout(r, 1000); + }); + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_BODY }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(loadingIndicator)).toBeVisible(); + + await expect(page.locator(promptInput)).toHaveValue(''); + await expect(page.locator(userChatEntry)).toContainText(PROMPT_SUBMITTED); + await expect(page.locator(aiChatEntry)).toContainText(MOCK_STREAMED_RESPONSE_TEXT); + + // Second prompt sends conversation_id + const PROMPT_SUBMITTED_2 = 'Test prompt 2'; + await page.locator(promptInput).fill(PROMPT_SUBMITTED_2); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(promptInput)).toHaveValue(''); + await expect(page.locator(userChatEntry).last()).toContainText(PROMPT_SUBMITTED_2); + await expect(page.locator(aiChatEntry).last()).toContainText(MOCK_STREAMED_RESPONSE_TEXT); + + // Clear chat preserves prompt text + await page.locator(promptInput).fill(PROMPT_NOT_SUBMITTED); + await page.locator(clearChatButton).click(); + await expect(page.locator(modal)).toContainText(CLEAR_CHAT_TEXT); + await page + .locator(modal) + .locator('button') + .filter({ hasText: CLEAR_CHAT_CONFIRM_BUTTON }) + .click(); + await expect(page.locator(userChatEntry)).toBeHidden(); + await expect(page.locator(aiChatEntry)).toBeHidden(); + await expect(page.locator(popover)).toContainText(FOOTER_TEXT); + await expect(page.locator(popover)).toContainText(PRIVACY_TEXT); + await expect(page.locator(popover).locator('h1')).toContainText(POPOVER_TITLE); + await expect(page.locator(promptInput)).toHaveValue(PROMPT_NOT_SUBMITTED); + }); + + test('Test response with error, partial response text and tool call', async ({ page }) => { + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + const errorBody = MOCK_STREAMED_RESPONSE_WITH_ERROR_BODY.replace( + 'MOCK_ERROR_MESSAGE', + MOCK_ERROR_MESSAGE, + ); + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: errorBody }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + + const aiEntry = page.locator(aiChatEntry); + await expect(aiEntry).toContainText(MOCK_PARTIAL_RESPONSE_TEXT); + await expect(aiEntry.locator('.pf-m-danger')).toContainText(MOCK_ERROR_MESSAGE); + + await expect(aiEntry.locator('.pf-v6-c-label').filter({ hasText: 'ABC' })).toBeVisible(); + }); + }); + + test.describe.serial('Tool approval (HITL)', { tag: ['@hitl'] }, () => { + test('Test approval card is shown and tool can be approved', async ({ page }) => { + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_WITH_APPROVAL_BODY }); + }); + await page.route(`**/v1/tool-approvals/decision`, async (route) => { + const body = route.request().postDataJSON(); + expect(body.approval_id).toBe('abc'); + expect(body.approved).toBe(true); + await route.fulfill({ status: 200, body: '{}' }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + + const card = page.locator(toolApprovalCard); + await expect(card).toBeVisible(); + await expect(card).toContainText('Review required'); + await expect(card).toContainText('This action will list pods in the cluster.'); + await expect(card.locator('button').filter({ hasText: 'Approve' })).toBeVisible(); + await expect(card.locator('button').filter({ hasText: 'Reject' })).toBeVisible(); + + await card.getByText('View action details').click(); + await expect(card).toContainText('mock_tool'); + await expect(card).toContainText('namespace'); + + await card.locator('button').filter({ hasText: 'Approve' }).click(); + await expect(card).toBeHidden(); + await expect(page.locator(toolLabel).filter({ hasText: 'mock_tool' })).toBeVisible(); + + await page.locator(toolLabel).filter({ hasText: 'mock_tool' }).click(); + const m = page.locator(attachmentModal); + await expect(m).toContainText('Tool output'); + await expect(m).toContainText('mock_tool'); + await expect(m).toContainText('Status'); + await expect(m).toContainText('pending'); + await expect(m).not.toContainText('Tool call rejected'); + await m.locator('.pf-v6-c-modal-box__close button').click(); + }); + + test('Test tool can be rejected', async ({ page }) => { + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_WITH_APPROVAL_BODY }); + }); + await page.route(`**/v1/tool-approvals/decision`, async (route) => { + const body = route.request().postDataJSON(); + expect(body.approval_id).toBe('abc'); + expect(body.approved).toBe(false); + await route.fulfill({ status: 200, body: '{}' }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + + const card = page.locator(toolApprovalCard); + await expect(card).toBeVisible(); + await card.locator('button').filter({ hasText: 'Reject' }).click(); + await expect(card).toBeHidden(); + await expect(page.locator(toolLabel).filter({ hasText: 'mock_tool' })).toBeVisible(); + await page.locator(toolLabel).filter({ hasText: 'mock_tool' }).click(); + const m = page.locator(attachmentModal); + await expect(m).toContainText('Tool call rejected'); + await expect(m).toContainText('mock_tool'); + await expect(m).not.toContainText('Status'); + await expect(m).not.toContainText('Content'); + await m.locator('.pf-v6-c-modal-box__close button').click(); + }); + }); + + test.describe.serial('User feedback', { tag: ['@feedback'] }, () => { + test('Test user feedback form', async ({ page }) => { + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_BODY }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(aiChatEntry)).toContainText(MOCK_STREAMED_RESPONSE_TEXT); + await expect(page.locator(responseAction)).toHaveCount(3); + + // Positive feedback with comment + await page.locator(responseAction).nth(0).click(); + await expect(page.locator(popover)).toContainText(USER_FEEDBACK_TEXT); + await page.route(`**/v1/feedback`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify({ message: 'Feedback received' }), + }); + }); + await page.locator(userFeedbackInput).fill(USER_FEEDBACK_SUBMITTED); + await page.locator(userFeedbackSubmit).click(); + await expect(page.locator(popover)).toContainText(USER_FEEDBACK_RECEIVED_TEXT); + + // Negative feedback with no comment + await page.unroute('**/v1/feedback'); + const negativeFeedbackPromise = interceptFeedback( + page, + CONVERSATION_ID, + THUMBS_DOWN, + '', + `${PROMPT_SUBMITTED}\n---\nThe attachments that were sent with the prompt are shown below.\n[]`, + ); + await page.locator(responseAction).nth(1).click(); + await expect(page.locator(popover)).toContainText(USER_FEEDBACK_TEXT); + await page.locator(userFeedbackInput).clear(); + await page.locator(userFeedbackSubmit).click(); + await negativeFeedbackPromise; + await expect(page.locator(popover)).toContainText(USER_FEEDBACK_RECEIVED_TEXT); + }); + }); + + test.describe.serial('Copy to clipboard', { tag: ['@clipboard'] }, () => { + test('Test copy response functionality', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_BODY }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(aiChatEntry)).toContainText(MOCK_STREAMED_RESPONSE_TEXT); + await expect(page.locator(copyResponseButton)).toBeVisible(); + await page.locator(copyResponseButton).click(); + + try { + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toBe(MOCK_STREAMED_RESPONSE_TEXT); + } catch { + // Clipboard access may be denied in headless mode + } + }); + + test('Test copy conversation functionality', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: MOCK_STREAMED_RESPONSE_BODY }); + }); + + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + await expect(page.locator(aiChatEntry).first()).toContainText(MOCK_STREAMED_RESPONSE_TEXT); + + const PROMPT_SUBMITTED_2 = 'Second test prompt'; + await page.locator(promptInput).fill(PROMPT_SUBMITTED_2); + await page.locator(promptInput).press('Enter'); + await expect(page.locator(aiChatEntry)).toHaveCount(2); + + await expect(page.locator(userChatEntry).first()).toContainText(PROMPT_SUBMITTED); + await expect(page.locator(userChatEntry).last()).toContainText(PROMPT_SUBMITTED_2); + + await page.locator(copyConversationButton).hover(); + await expect(page.locator(copyConversationTooltip)).toBeVisible(); + await expect(page.locator(copyConversationTooltip)).toContainText('Copy conversation'); + + await page.locator(copyConversationButton).click(); + + await expect(page.locator(copyConversationTooltip)).toContainText('Copied'); + await expect(page.locator(copyConversationTooltip)).toContainText('Copy conversation', { + timeout: 3000, + }); + + await page.locator(clearChatButton).click(); + await page + .locator(modal) + .locator('button') + .filter({ hasText: 'Erase and start new chat' }) + .click(); + await expect(page.locator(copyConversationButton)).toBeHidden(); + }); + }); + + test.describe.serial('Attach menu', { tag: ['@attach'] }, () => { + test('Test attach options on pods list page', async ({ page }) => { + await goToPodsList(page, 'openshift-console'); + await openPopover(page); + + await filterByName(page, podNamePrefix); + await expect(page.locator(resourceRows).first()).toBeVisible({ + timeout: 2 * MINUTE, + }); + const rowCount = await page.locator(resourceRows).count(); + expect(rowCount).toBeGreaterThanOrEqual(1); + + await page.locator(attachButton).click(); + const menu = page.locator(attachMenu); + await expect(menu).toContainText('Upload from computer'); + await expect(menu).not.toContainText('YAML'); + await expect(menu).not.toContainText('Events'); + await expect(menu).not.toContainText('Logs'); + }); + + test('Test attaching YAML', async ({ page }) => { + await goToPodDetails(page, 'openshift-console', podNamePrefix); + await openPopover(page); + + await expect(page.locator(attachments)).toBeEmpty(); + + await page.locator(attachButton).click(); + const menu = page.locator(attachMenu); + await expect(menu).toContainText('Full YAML file'); + await expect(menu).toContainText('Filtered YAML'); + await expect(menu).toContainText('Events'); + await expect(menu).toContainText('Logs'); + await expect(menu).toContainText('Upload from computer'); + + await menu.locator('button').filter({ hasText: 'Full YAML file' }).click(); + const att = page.locator(attachments); + await expect(att).toContainText(podNamePrefix); + await expect(att).toContainText('YAML'); + const attBtn = att.locator('button').filter({ hasText: podNamePrefix }); + await expect(attBtn).toHaveCount(1); + await attBtn.click(); + const m = page.locator(attachmentModal); + await expect(m).toContainText('Preview attachment'); + await expect(m).toContainText(podNamePrefix); + await expect(m).toContainText('kind: Pod'); + await expect(m).toContainText('apiVersion: v1'); + await m.locator('button').filter({ hasText: 'Dismiss' }).click(); + await page.locator(promptInput).fill('Test'); + await page.locator(promptInput).press('Enter'); + + await page.locator(attachButton).click(); + await menu.locator('button').filter({ hasText: 'Filtered YAML' }).click(); + await expect(att).toContainText(podNamePrefix); + await expect(att).toContainText('YAML'); + const attBtn2 = att.locator('button').filter({ hasText: podNamePrefix }); + await expect(attBtn2).toHaveCount(1); + await attBtn2.click(); + await expect(m).toContainText('Preview attachment'); + await expect(m).toContainText(podNamePrefix); + await expect(m).toContainText('kind: Pod'); + await expect(m).not.toContainText('apiVersion: v1'); + await m.locator('button').filter({ hasText: 'Dismiss' }).click(); + await page.locator(promptInput).fill('Test'); + await page.locator(promptInput).press('Enter'); + }); + + test('Test modifying attached YAML', async ({ page }) => { + await goToPodDetails(page, 'openshift-console', podNamePrefix); + await openPopover(page); + + await page.locator(attachButton).click(); + await page + .locator(attachMenu) + .locator('button') + .filter({ hasText: 'Full YAML file' }) + .click(); + await page.locator(promptAttachment).click(); + const m = page.locator(attachmentModal); + await m.locator('button').filter({ hasText: 'Dismiss' }).click(); + await page.locator(promptAttachment).click(); + await m.locator('button').filter({ hasText: 'Edit' }).click(); + await m.locator('button').filter({ hasText: 'Cancel' }).click(); + await m.locator('button').filter({ hasText: 'Edit' }).click(); + await expect(m.locator('.ols-plugin__code-block__title')).toBeVisible(); + await expect(m.locator('.ols-plugin__code-block__title')).toContainText(podNamePrefix); + await expect(m.locator('.monaco-editor')).toBeVisible(); + await expect(m.locator('.monaco-editor')).toContainText(podNamePrefix); + await setEditorContent(page, 'Test modifying YAML'); + await m.locator('button').filter({ hasText: 'Save' }).click(); + await page.locator(promptAttachment).click(); + await expect(m.locator('.ols-plugin__code-block-code')).toBeVisible(); + await expect(m.locator('.ols-plugin__code-block-code')).toContainText('Test modifying YAML'); + }); + + test('Test attaching events', async ({ page }) => { + await goToPodDetails(page, 'openshift-lightspeed', podNamePrefix); + await openPopover(page); + + await page.locator(attachButton).click(); + await page.locator(attachMenu).locator('button').filter({ hasText: 'Events' }).click(); + const eventsModal = page.locator(modal).filter({ hasText: 'Configure events attachment' }); + await expect(eventsModal).toBeVisible(); + await eventsModal.locator('button').filter({ hasText: 'Attach' }).click(); + const att = page.locator(attachments); + await expect(att).toContainText(podNamePrefix); + await expect(att).toContainText('Events'); + const attBtn = att.locator('button').filter({ hasText: podNamePrefix }); + await expect(attBtn).toHaveCount(1); + await attBtn.click(); + const previewModal = page.locator(modal).filter({ hasText: 'Preview attachment' }); + await expect(previewModal).toContainText(podNamePrefix); + await expect(previewModal).toContainText('kind: Event'); + await previewModal.locator('button').filter({ hasText: 'Dismiss' }).click(); + + /* eslint-disable camelcase */ + const queryPromise = interceptQuery(page, PROMPT_SUBMITTED, null, [ + { attachment_type: 'event', content_type: 'application/yaml' }, + ]); + /* eslint-enable camelcase */ + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + await queryPromise; + + const feedbackPromise = interceptFeedback( + page, + CONVERSATION_ID, + THUMBS_UP, + USER_FEEDBACK_SUBMITTED, + `${PROMPT_SUBMITTED}\n---\nThe attachments that were sent with the prompt are shown below.\n[\n {\n "attachment_type": "event",\n "content": "- kind: Event`, + ); + + await page.locator(responseAction).nth(0).click(); + await page.locator(userFeedbackInput).fill(USER_FEEDBACK_SUBMITTED); + await page.locator(userFeedbackSubmit).click(); + await feedbackPromise; + await expect(page.locator(popover)).toContainText(USER_FEEDBACK_RECEIVED_TEXT); + }); + + test('Test attaching logs', async ({ page }) => { + await goToPodDetails(page, 'openshift-console', podNamePrefix); + await openPopover(page); + + await page.locator(attachButton).click(); + await page.locator(attachMenu).locator('button').filter({ hasText: 'Logs' }).click(); + const logModal = page.locator(modal).filter({ hasText: 'Configure log attachment' }); + await expect(logModal).toBeVisible(); + await expect(logModal).toContainText('Most recent 25 lines'); + await logModal.locator('button').filter({ hasText: 'Attach' }).click(); + const att = page.locator(attachments); + await expect(att).toContainText(podNamePrefix); + await expect(att).toContainText('Log'); + const attBtn = att.locator('button').filter({ hasText: podNamePrefix }); + await expect(attBtn).toHaveCount(1); + await attBtn.click(); + const previewModal = page.locator(modal).filter({ hasText: 'Preview attachment' }); + await expect(previewModal).toContainText(podNamePrefix); + await expect(previewModal).toContainText('Most recent lines from the log for'); + await previewModal.locator('button').filter({ hasText: 'Dismiss' }).click(); + + /* eslint-disable camelcase */ + const queryPromise = interceptQuery(page, PROMPT_SUBMITTED, null, [ + { attachment_type: 'log', content_type: 'text/plain' }, + ]); + /* eslint-enable camelcase */ + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + await queryPromise; + }); + + test('Test file upload', async ({ page }) => { + const MAX_FILE_SIZE_MB = 1; + + await page.goto('/search/all-namespaces'); + await expect(page.locator('h1').filter({ hasText: 'Search' })).toBeVisible(); + await openPopover(page); + await page.locator(attachButton).click(); + await page + .locator(attachMenu) + .locator('button') + .filter({ hasText: 'Upload from computer' }) + .click(); + + // Invalid YAML + await page.locator(fileInput).setInputFiles({ + name: 'test.yaml', + mimeType: 'application/x-yaml', + buffer: Buffer.from('abc'), + }); + await expect(page.locator(popover)).toContainText('Uploaded file is not valid YAML'); + + // File too large + const largeFileContent = 'a'.repeat(MAX_FILE_SIZE_MB * 1024 * 1024 + 1); + await page.locator(fileInput).setInputFiles({ + name: 'large.yaml', + mimeType: 'application/x-yaml', + buffer: Buffer.from(largeFileContent), + }); + await expect(page.locator(popover)).toContainText( + `Uploaded file is too large. Max size is ${MAX_FILE_SIZE_MB} MB.`, + ); + + // Valid YAML + await page.locator(fileInput).setInputFiles({ + name: 'valid.yaml', + mimeType: 'application/x-yaml', + buffer: Buffer.from(` +kind: Pod +metadata: + name: my-test-pod + namespace: test-namespace +`), + }); + await expect(page.locator(popover)).not.toContainText('Uploaded file is not valid YAML'); + await expect(page.locator(attachments)).toContainText('my-test-pod'); + }); + }); + + test.describe('ACM', { tag: ['@acm'] }, () => { + test.skip('Test attach cluster info for ManagedCluster', async ({ page }) => { + await page.goto( + '/k8s/ns/test-cluster/cluster.open-cluster-management.io~v1~ManagedCluster/test-cluster', + ); + await openPopover(page); + + await page.locator(attachButton).click(); + const menu = page.locator(attachMenu); + await expect(menu).toContainText(ACM_ATTACH_CLUSTER_TEXT); + await expect(menu).toContainText('Upload from computer'); + await expect(menu).not.toContainText('Full YAML file'); + await expect(menu).not.toContainText('Filtered YAML'); + await expect(menu).not.toContainText('Events'); + await expect(menu).not.toContainText('Logs'); + + const getManagedCluster = page.waitForResponse( + (resp) => + resp + .url() + .includes('/apis/cluster.open-cluster-management.io/v1/managedclusters/test-cluster') && + resp.status() === 200, + ); + const getManagedClusterInfo = page.waitForResponse( + (resp) => + resp + .url() + .includes( + '/apis/internal.open-cluster-management.io/v1beta1/namespaces/test-cluster/managedclusterinfos/test-cluster', + ) && resp.status() === 200, + ); + + await page.route( + '**/apis/cluster.open-cluster-management.io/v1/managedclusters/test-cluster', + (route) => + route.fulfill({ + status: 200, + json: { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name: 'test-cluster', namespace: 'test-cluster' }, + spec: { hubAcceptsClient: true }, + status: { + conditions: [ + { + type: 'ManagedClusterConditionAvailable', + status: 'True', + }, + ], + }, + }, + }), + ); + + await page.route( + '**/apis/internal.open-cluster-management.io/v1beta1/namespaces/test-cluster/managedclusterinfos/test-cluster', + (route) => + route.fulfill({ + status: 200, + json: { + kind: 'ManagedClusterInfo', + apiVersion: 'internal.open-cluster-management.io/v1beta1', + metadata: { name: 'test-cluster', namespace: 'test-cluster' }, + status: { + distributionInfo: { type: 'OCP', ocp: { version: '4.14.0' } }, + nodeList: [ + { + name: 'master-0', + conditions: [{ type: 'Ready', status: 'True' }], + }, + ], + }, + }, + }), + ); + + await menu.locator('button').filter({ hasText: ACM_ATTACH_CLUSTER_TEXT }).click(); + + await getManagedCluster; + await getManagedClusterInfo; + + const att = page.locator(attachments); + await expect(att).toContainText('test-cluster'); + await expect(att).toContainText('YAML'); + await expect(att.locator('button')).toHaveCount(2); + + await att.locator('button').filter({ hasText: 'test-cluster' }).first().click(); + const m = page.locator(attachmentModal); + await expect(m).toContainText('Preview attachment'); + await expect(m).toContainText('test-cluster'); + await expect(m).toContainText('kind: ManagedCluster'); + await expect(m).toContainText('apiVersion: cluster.open-cluster-management.io/v1'); + await m.locator('button').filter({ hasText: 'Dismiss' }).click(); + + await att.locator('button').filter({ hasText: 'test-cluster' }).last().click(); + await expect(m).toContainText('Preview attachment'); + await expect(m).toContainText('test-cluster'); + await expect(m).toContainText('kind: ManagedClusterInfo'); + await expect(m).toContainText('apiVersion: internal.open-cluster-management.io/v1beta1'); + await expect(m).toContainText('distributionInfo'); + await m.locator('button').filter({ hasText: 'Dismiss' }).click(); + + /* eslint-disable camelcase */ + const queryPromise = interceptQuery(page, PROMPT_SUBMITTED, null, [ + { attachment_type: 'yaml', content_type: 'application/yaml' }, + { attachment_type: 'yaml', content_type: 'application/yaml' }, + ]); + /* eslint-enable camelcase */ + await page.locator(promptInput).fill(PROMPT_SUBMITTED); + await page.locator(promptInput).press('Enter'); + await queryPromise; + }); + + test.skip('Test ManagedCluster attachment error handling', async ({ page }) => { + await page.goto( + '/k8s/ns/test-cluster/cluster.open-cluster-management.io~v1~ManagedCluster/test-cluster', + ); + await openPopover(page); + + await page.route( + '**/apis/cluster.open-cluster-management.io/v1/managedclusters/test-cluster', + (route) => + route.fulfill({ + status: 200, + json: { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name: 'test-cluster', namespace: 'test-cluster' }, + }, + }), + ); + await page.route( + '**/apis/internal.open-cluster-management.io/v1beta1/namespaces/test-cluster/managedclusterinfos/test-cluster', + (route) => + route.fulfill({ + status: 404, + json: { + kind: 'Status', + message: + 'managedclusterinfos.internal.open-cluster-management.io "test-cluster" not found', + }, + }), + ); + + await page.locator(attachButton).click(); + await page + .locator(attachMenu) + .locator('button') + .filter({ hasText: ACM_ATTACH_CLUSTER_TEXT }) + .click(); + + await expect(page.locator(attachMenu)).toContainText('Error fetching cluster info'); + }); + + test.skip('Test ACM search resources page context for Pod', async ({ page }) => { + await page.goto( + '/multicloud/search/resources?kind=Pod&name=test-pod&namespace=test-namespace', + ); + + await page.route( + '**/api/kubernetes/api/v1/namespaces/test-namespace/pods/test-pod', + (route) => + route.fulfill({ + status: 200, + json: { + kind: 'Pod', + metadata: { + name: 'test-pod', + namespace: 'test-namespace', + }, + }, + }), + ); + + await openPopover(page); + + await page.locator(attachButton).click(); + const menu = page.locator(attachMenu); + await expect(menu).toContainText('Upload from computer'); + await expect(menu).toContainText('Full YAML file'); + await expect(menu).toContainText('Filtered YAML'); + await expect(menu).toContainText('Events'); + await expect(menu).toContainText('Logs'); + await expect(menu).not.toContainText(ACM_ATTACH_CLUSTER_TEXT); + }); + + test.skip('Test ACM search resources page context for VirtualMachine', async ({ page }) => { + await page.goto( + '/multicloud/search/resources?kind=VirtualMachine&name=test-vm&namespace=test-namespace', + ); + + await page.route( + '**/apis/kubevirt.io/v1/namespaces/test-namespace/virtualmachines/test-vm', + (route) => + route.fulfill({ + status: 200, + json: { + kind: 'VirtualMachine', + apiVersion: 'kubevirt.io/v1', + metadata: { + name: 'test-vm', + namespace: 'test-namespace', + }, + }, + }), + ); + + await openPopover(page); + + await page.locator(attachButton).click(); + const menu = page.locator(attachMenu); + await expect(menu).toContainText('Upload from computer'); + await expect(menu).toContainText('Full YAML file'); + await expect(menu).toContainText('Filtered YAML'); + await expect(menu).toContainText('Events'); + await expect(menu).toContainText('Logs'); + await expect(menu).not.toContainText(ACM_ATTACH_CLUSTER_TEXT); + }); + }); + + test.describe('MCP Iframe Rendering', { tag: ['@mcp', '@mcp-mocked', '@iframe'] }, () => { + const mcpAppIframe = '.ols-plugin__mcp-app-iframe'; + const mcpAppCard = '.ols-plugin__mcp-app'; + const mcpAppLoading = `${mcpAppCard} .pf-v6-c-spinner`; + const mcpAppError = '.ols-plugin__alert'; + + const MCP_PROMPT = 'Show me the dashboard'; + const MCP_TOOL_NAME = 'dashboard'; + const MCP_UI_RESOURCE_URI = 'mcp://test-server/resources/dashboard'; + + const SAMPLE_MCP_HTML = ` + + +

MCP Dashboard

+

Resource Dashboard

+

CPU Usage

+

45%

+ +`; + + test.beforeEach(async ({ page }) => { + await page.route('**/api/proxy/plugin/lightspeed-console-plugin/ols/readiness', (route) => + route.fulfill({ status: 200, json: { ready: true } }), + ); + await page.route('**/api/proxy/plugin/lightspeed-console-plugin/ols/authorized', (route) => + route.fulfill({ + status: 200, + /* eslint-disable camelcase */ + json: { + user_id: 'test-user-id', + username: 'test-user', + skip_user_id_check: false, + }, + /* eslint-enable camelcase */ + }), + ); + + await page.goto('/'); + await openPopover(page); + await expect(page.locator(promptInput)).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(promptInput)).toBeEnabled(); + }); + + test( + 'renders iframe when MCP response includes uiResourceUri', + { tag: ['@core'] }, + async ({ page }) => { + await page.route(`**/v1/streaming_query`, async (route) => { + const responseBody = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( + 'CONVERSATION_ID', + CONVERSATION_ID, + ) + .replace('TOOL_NAME', MCP_TOOL_NAME) + .replace('UI_RESOURCE_URI', MCP_UI_RESOURCE_URI); + await route.fulfill({ body: responseBody }); + }); + + await page.route(`**/v1/mcp-apps/resources`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify({ content: SAMPLE_MCP_HTML }), + }); + }); + + await page.locator(promptInput).fill(MCP_PROMPT); + await page.locator(promptInput).press('Enter'); + + const iframe = page.locator(mcpAppIframe); + await expect(iframe).toBeVisible({ timeout: 30_000 }); + await expect(iframe).toHaveAttribute('sandbox', 'allow-scripts'); + await expect(page.locator(mcpAppCard)).toBeVisible(); + }, + ); + + test('iframe srcDoc contains expected HTML content', { tag: ['@core'] }, async ({ page }) => { + await page.route(`**/v1/streaming_query`, async (route) => { + const responseBody = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( + 'CONVERSATION_ID', + CONVERSATION_ID, + ) + .replace('TOOL_NAME', MCP_TOOL_NAME) + .replace('UI_RESOURCE_URI', MCP_UI_RESOURCE_URI); + await route.fulfill({ body: responseBody }); + }); + + await page.route(`**/v1/mcp-apps/resources`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify({ content: SAMPLE_MCP_HTML }), + }); + }); + + await page.locator(promptInput).fill(MCP_PROMPT); + await page.locator(promptInput).press('Enter'); + + const iframe = page.locator(mcpAppIframe); + await expect(iframe).toBeVisible({ timeout: 30_000 }); + const srcDoc = await iframe.getAttribute('srcdoc'); + expect(srcDoc).toBeTruthy(); + expect(srcDoc).toContain('MCP Dashboard'); + expect(srcDoc).toContain('Resource Dashboard'); + expect(srcDoc).toContain('CPU Usage'); + expect(srcDoc).toContain('45%'); + expect(srcDoc).toContain('data-theme='); + }); + + test('displays loading state while fetching MCP resources', async ({ page }) => { + const mcpQueryBody = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( + 'CONVERSATION_ID', + CONVERSATION_ID, + ) + .replace('TOOL_NAME', MCP_TOOL_NAME) + .replace('UI_RESOURCE_URI', MCP_UI_RESOURCE_URI); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: mcpQueryBody }); + }); + await page.route('**/v1/mcp-apps/resources', async (route) => { + await new Promise((r) => { + setTimeout(r, 2000); + }); + await route.fulfill({ + status: 200, + body: JSON.stringify({ content: SAMPLE_MCP_HTML }), + }); + }); + + await page.locator(promptInput).fill(MCP_PROMPT); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(mcpAppLoading)).toBeVisible({ + timeout: 5000, + }); + await expect(page.locator(mcpAppLoading)).toBeHidden({ + timeout: 10_000, + }); + await expect(page.locator(mcpAppIframe)).toBeVisible(); + }); + + test('displays error when resource fetch fails', async ({ page }) => { + const mcpQueryBody = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( + 'CONVERSATION_ID', + CONVERSATION_ID, + ) + .replace('TOOL_NAME', MCP_TOOL_NAME) + .replace('UI_RESOURCE_URI', MCP_UI_RESOURCE_URI); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: mcpQueryBody }); + }); + await page.route('**/v1/mcp-apps/resources', (route) => + route.fulfill({ + status: 500, + json: { error: 'Failed to fetch MCP resource' }, + }), + ); + + await page.locator(promptInput).fill(MCP_PROMPT); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(mcpAppError).filter({ hasText: 'MCP App Error' })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(mcpAppIframe)).toBeHidden(); + }); + + test('does not render iframe when uiResourceUri is missing', async ({ page }) => { + const responseWithoutURI = `data: {"event": "start", "data": {"conversation_id": "${CONVERSATION_ID}"}} + +data: {"event": "token", "data": {"id": 0, "token": "Here"}} + +data: {"event": "token", "data": {"id": 1, "token": " is"}} + +data: {"event": "token", "data": {"id": 2, "token": " your"}} + +data: {"event": "token", "data": {"id": 3, "token": " data"}} + +data: {"event": "tool_call", "data": {"id": 1, "name": "get_data", "server_name": "test-server", "args": {}}} + +data: {"event": "tool_result", "data": {"id": 1, "content": "Data retrieved", "status": "success"}} + +data: {"event": "end", "data": {"referenced_documents": [], "truncated": false}} +`; + + await page.route('**/v1/streaming_query', async (route) => { + await route.fulfill({ body: responseWithoutURI }); + }); + + await page.locator(promptInput).fill(MCP_PROMPT); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(aiChatEntry)).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(mcpAppIframe)).toBeHidden(); + }); + + test('handles multiple MCP iframes in conversation', async ({ page }) => { + const SECOND_PROMPT = 'Show me another dashboard'; + const SECOND_TOOL_NAME = 'metrics'; + const SECOND_URI = 'mcp://test-server/resources/metrics'; + + const SECOND_HTML = ` + +Metrics +
Metrics Dashboard
+`; + + const mcpQueryBody1 = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( + 'CONVERSATION_ID', + CONVERSATION_ID, + ) + .replace('TOOL_NAME', MCP_TOOL_NAME) + .replace('UI_RESOURCE_URI', MCP_UI_RESOURCE_URI); + + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: mcpQueryBody1 }); + }); + await page.route(`**/v1/mcp-apps/resources`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify({ content: SAMPLE_MCP_HTML }), + }); + }); + + await page.locator(promptInput).fill(MCP_PROMPT); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(mcpAppIframe)).toHaveCount(1); + + const mcpQueryBody2 = MOCK_MCP_STREAMED_RESPONSE_BODY_TEMPLATE.replace( + 'CONVERSATION_ID', + CONVERSATION_ID, + ) + .replace('TOOL_NAME', SECOND_TOOL_NAME) + .replace('UI_RESOURCE_URI', SECOND_URI); + + await page.unroute('**/v1/streaming_query'); + await page.unroute('**/v1/mcp-apps/resources'); + await page.route(`**/v1/streaming_query`, async (route) => { + await route.fulfill({ body: mcpQueryBody2 }); + }); + await page.route(`**/v1/mcp-apps/resources`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify({ content: SECOND_HTML }), + }); + }); + + await page.locator(promptInput).fill(SECOND_PROMPT); + await page.locator(promptInput).press('Enter'); + + await expect(page.locator(mcpAppIframe)).toHaveCount(2); + }); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 1c03216c..e60dfff5 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,9 +1,7 @@ { "compilerOptions": { "types": [ - "cypress", - "node", - "@cypress/grep" + "node" ] }, "include": [ diff --git a/tests/views/operator-hub-page.ts b/tests/views/operator-hub-page.ts deleted file mode 100644 index 83098074..00000000 --- a/tests/views/operator-hub-page.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as helperfuncs from '../views/utils'; - -export const operatorHubPage = { - installOperator: (operatorName, csName, installNamespace?) => { - cy.visit( - `/operatorhub/subscribe?pkg=${operatorName}&catalog=${csName}&catalogNamespace=openshift-marketplace&targetNamespace=undefined`, - ); - cy.get('body').should('be.visible'); - if (installNamespace) { - cy.get('[data-test="A specific namespace on the cluster-radio-input"]').click(); - helperfuncs.clickIfExist('input[data-test="Select a Namespace-radio-input"]'); - cy.get('button#dropdown-selectbox').click(); - cy.contains('span', `${installNamespace}`).click(); - } - cy.get('[data-test="install-operator"]').click(); - }, - checkOperatorStatus: (csvName, csvStatus) => { - cy.get('input[data-test="name-filter-input"]').clear().type(`${csvName}`); - cy.get(`[data-test-operator-row="${csvName}"]`, { timeout: 120000 }) - .parents('tr') - .children() - .contains(`${csvStatus}`, { timeout: 120000 }); - }, -}; diff --git a/tests/views/pages.ts b/tests/views/pages.ts deleted file mode 100644 index 0bfbacb0..00000000 --- a/tests/views/pages.ts +++ /dev/null @@ -1,58 +0,0 @@ -export const getEditorContent = () => - cy.window().then((win: any) => win.monaco.editor.getModels()[0].getValue()); // eslint-disable-line @typescript-eslint/no-explicit-any - -export const setEditorContent = (text: string) => - cy.window().then((win: any) => win.monaco.editor.getModels()[0].setValue(text)); // eslint-disable-line @typescript-eslint/no-explicit-any - -// Initially yamlEditor loads with all grey text, finished loading when editor is color coded -// class='mtk26' is the light blue color of property such as 'apiVersion' -export const isLoaded = () => cy.get("[class='mtk26']").should('exist'); -// Since yaml editor class mtk26 is a font class it doesn't work on an import page with no text -// adding a check for the 1st line number, AND providing a wait allowed the load of the full component -export const isImportLoaded = () => { - cy.wait(5000); - cy.get('.monaco-editor textarea:first').should('exist'); -}; -export const clickSaveCreateButton = () => cy.byTestID('save-changes').click(); -export const clickCancelButton = () => cy.byTestID('cancel').click(); -export const clickReloadButton = () => cy.byTestID('reload-object').click(); - -export const listPage = { - filter: { - byName: (name: string) => { - cy.byTestID('name-filter-input', { timeout: 10000 }) - .should('be.visible') - .type(name, { force: true }); - }, - }, - rows: { - clickFirst: () => { - cy.get('a.co-resource-item__resource-name').eq(0).click(); - }, - countShouldBe: (count: number) => { - cy.get('[data-test-rows="resource-row"]').should('have.length', count); - }, - countShouldBeWithin: (min: number, max: number) => { - cy.get('[data-test-rows="resource-row"]').should('have.length.within', min, max); - }, - shouldBeLoaded: () => { - cy.get('[data-test-rows="resource-row"]').should('be.visible'); - }, - shouldExist: (resourceName: string) => { - cy.get('[data-test-rows="resource-row"]').contains(resourceName); - }, - }, -}; - -export const pages = { - goToPodDetails: (ns, podName) => { - pages.goToPodsList(ns); - listPage.filter.byName(podName); - listPage.rows.countShouldBeWithin(1, 4); - listPage.rows.clickFirst(); - }, - goToPodsList: (ns: string | null = null) => { - cy.visit(ns ? `/k8s/ns/${ns}/pods` : '/k8s/all-namespaces/pods'); - listPage.rows.shouldBeLoaded(); - }, -}; diff --git a/tests/views/utils.ts b/tests/views/utils.ts deleted file mode 100644 index 213d0845..00000000 --- a/tests/views/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function clickIfExist(element) { - cy.get('body').then((body) => { - if (body.find(element).length > 0) { - cy.get(element).click(); - } - }); -}