diff --git a/README.md b/README.md index fa19b01..989e75b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Cypress plugin for effective API testing. Imagine Postman, but in Cypress. Print - calculating size of the response - [combine API calls with UI](#snapshot-only-mode) - [hide sensitive headers and auth information](#hiding-credentials) +- [`disableUi` to skip rendering for performance (auto in run mode/CI)](#disabling-the-ui-for-performance) - [`requestMode` to add cy.api() features to cy.request() command](#requestmode---enable-ui-for-cyrequest-command) - [TypeScript support](#typescript-support) @@ -71,6 +72,44 @@ export default defineConfig({ }) ``` +#### Disabling the UI for performance +Rendering the plugin UI is expensive: every `cy.api()` call syntax-highlights the entire request/response body and mounts it into the DOM. During `cypress run` / CI this UI can't be interacted with (not even in Test Replay), so it's pure overhead and can crash the browser renderer on large responses. + +Because of this, the UI is **automatically skipped in run mode** (`cypress run`) and rendered in open mode (`cypress open`). The request still runs and the response is still available in the command log console (and as the yielded value), only the heavy rendering is skipped. + +You can control this with the `disableUi` option: + +- **unset (default):** render in open mode, skip in run mode. +- **`true`:** always skip the UI (also speeds up open mode). +- **`false`:** always render, even in run mode (opt back into full Test Replay capture). + +```js +it('my test', () => { + Cypress.expose('disableUi', true) // skip UI rendering everywhere + cy.api('/item').then((res) => { + expect(res.status).to.eq(200) // data is still available + }) +}) +``` + +or add to your `cypress.config.{js,ts}` file: +```js +import { defineConfig } from 'cypress' + +export default defineConfig({ + e2e: { + setupNodeEvents(on, config) { + }, + }, + expose: { + // force the UI to render even in run mode + disableUi: false, + }, +}) +``` + +> On Cypress versions before 15.10 (which don't support `Cypress.expose`), use `Cypress.env('disableUi', true)` or the `env` block in your config instead. + #### Hiding credentials By default, values are **shown** in the UI so you can see them when running locally. When you use **cy.env()** for secrets (or want to hide in CI), turn on **HideCredentials** so those values are masked (e.g. `****`) in request headers, auth, body, query params, and the cURL tab. diff --git a/cypress.config.ts b/cypress.config.ts index bcd5239..dd4374b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ expose: { enableTimeline: true, requestMode: false, + // Force UI rendering even in run mode so the plugin's own UI tests work under `cypress run`. + // (By default the UI is skipped in run mode for performance — see disableUi.cy.ts.) + disableUi: false, }, allowCypressEnv: true, e2e: { diff --git a/cypress/e2e/disableUi.cy.ts b/cypress/e2e/disableUi.cy.ts new file mode 100644 index 0000000..08f7e6c --- /dev/null +++ b/cypress/e2e/disableUi.cy.ts @@ -0,0 +1,41 @@ +function setPluginConfig(key: string, value: unknown) { + const fn = (window as unknown as { setPluginConfig?: (k: string, v: unknown) => void }).setPluginConfig + if (typeof fn === 'function') fn(key, value) + else if (typeof (Cypress as unknown as { expose?: unknown }).expose === 'function') (Cypress as unknown as { expose: (k: string, v: unknown) => void }).expose(key, value) + else Cypress.env(key, value) +} + +describe('disableUi mode', () => { + + afterEach(() => { + // Restore the suite default (config forces UI on so other specs render). + setPluginConfig('disableUi', false) + }) + + it('disableUi: true skips rendering the plugin UI but still yields the response', () => { + setPluginConfig('disableUi', true) + cy.api('/').then((res) => { + expect(res.status).to.eq(200) + }) + // No Vue app mounted and no response body rendered into the DOM. + cy.get('#api-plugin-root').should('not.exist') + cy.get('[data-cy="responseBody"]').should('not.exist') + }) + + it('disableUi: false renders the UI even in run mode', () => { + setPluginConfig('disableUi', false) + cy.api('/') + cy.get('[data-cy="responseBody"]').should('be.visible') + }) + + it('switching disableUi off again restores rendering', () => { + setPluginConfig('disableUi', true) + cy.api('/') + cy.get('[data-cy="responseBody"]').should('not.exist') + + setPluginConfig('disableUi', false) + cy.api('/') + cy.get('[data-cy="responseBody"]').should('be.visible') + }) + +}) diff --git a/src/modules/api.ts b/src/modules/api.ts index 93875e3..86ec2fa 100644 --- a/src/modules/api.ts +++ b/src/modules/api.ts @@ -6,6 +6,7 @@ import { initialize } from './initialize'; import { transformData } from './transformData'; import { cloneProps } from './cloneProps'; import { getPluginConfig } from '@utils/pluginConfig'; +import { shouldRenderUi } from '@utils/shouldRenderUi'; const requestFn = cy.request.bind({}) @@ -17,7 +18,8 @@ export const api = (...params: Partial[]) => { cloneProps(props, index, options) // Only mask when hideCredentials is explicitly true (e.g. when using cy.env() for secrets or in CI). Default off so locals can see values. if (getPluginConfig('hideCredentials')) props[index] = anonymize(props[index]) - transformData(props, index) + // Syntax highlighting is only needed for the UI; skip it when the UI is disabled. + if (shouldRenderUi()) transformData(props, index) return requestFn({ ...options, log: false }).then(res => handleResponse(res, options, props, index, app)) } \ No newline at end of file diff --git a/src/modules/handleResponse.ts b/src/modules/handleResponse.ts index 67ec6aa..c126b49 100644 --- a/src/modules/handleResponse.ts +++ b/src/modules/handleResponse.ts @@ -7,13 +7,15 @@ import { ApiRequestOptions, ApiResponseBody, RequestProps } from '../types'; import { transform } from "@modules/transform"; import { getState } from '@utils/getState'; import { getPluginConfig } from '@utils/pluginConfig'; +import { shouldRenderUi } from '@utils/shouldRenderUi'; import { App } from 'vue'; import { getFormat } from '@utils/getFormat'; import { isValidUrlOrIp } from '@utils/isValidUrlOrIp'; -export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, props: RequestProps[], index: number, app: App) => { +export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, props: RequestProps[], index: number, app: App | null) => { const { doc, testId } = getState() + const render = shouldRenderUi() if (!props[index].url || props[index].url === '') { const baseUrl = Cypress.config('baseUrl') @@ -32,7 +34,8 @@ export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, name: options.method || 'GET', autoEnd: false, message: `${options.url}` - }).snapshot('request') + }) + if (render) log.snapshot('request') const { body, status, headers, statusText, duration } = res @@ -117,31 +120,35 @@ export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, props[index].responseBody.body = bodyRaw - if (contentTypeHeader) { - const contentType = contentTypeHeader.split(';')[0] - const formats = { - 'text/xml': 'xml', - 'application/json': 'json', - 'text/html': 'html', - 'text/plain': 'plaintext', - 'application/octet-stream': 'plaintext', - } as const - const definedFormat = formats[contentType as keyof typeof formats] - const rawLanguage = definedFormat || getFormat(bodyForTransform) - // Prism registers XML/HTML under "markup"; use it for highlighting - const language = (rawLanguage === 'xml' || rawLanguage === 'html') ? 'markup' : rawLanguage - props[index].responseBody.formatted = transform(bodyForTransform, language) - } else if (body !== undefined && body !== null && body !== '') { - const rawLanguage = getFormat(bodyForTransform) - const language = (rawLanguage === 'xml' || rawLanguage === 'html') ? 'markup' : rawLanguage - props[index].responseBody.formatted = transform(bodyForTransform, language) - } + // Syntax highlighting (Prism) is only needed to render the UI. Skip it when the UI is + // disabled (e.g. run mode) — this is the expensive work that crashes large responses (#135). + if (render) { + if (contentTypeHeader) { + const contentType = contentTypeHeader.split(';')[0] + const formats = { + 'text/xml': 'xml', + 'application/json': 'json', + 'text/html': 'html', + 'text/plain': 'plaintext', + 'application/octet-stream': 'plaintext', + } as const + const definedFormat = formats[contentType as keyof typeof formats] + const rawLanguage = definedFormat || getFormat(bodyForTransform) + // Prism registers XML/HTML under "markup"; use it for highlighting + const language = (rawLanguage === 'xml' || rawLanguage === 'html') ? 'markup' : rawLanguage + props[index].responseBody.formatted = transform(bodyForTransform, language) + } else if (body !== undefined && body !== null && body !== '') { + const rawLanguage = getFormat(bodyForTransform) + const language = (rawLanguage === 'xml' || rawLanguage === 'html') ? 'markup' : rawLanguage + props[index].responseBody.formatted = transform(bodyForTransform, language) + } - if (!props[index].responseBody.formatted || !props[index].responseBody.formatted.length) { - if (bodyRaw && typeof bodyRaw === 'string' && bodyRaw.trim().length > 0) { - props[index].responseBody.formatted = transform(bodyRaw, 'plaintext') - } else if (bodyRaw && typeof bodyRaw === 'object' && bodyRaw !== null) { - props[index].responseBody.formatted = transform(bodyRaw, 'json') + if (!props[index].responseBody.formatted || !props[index].responseBody.formatted.length) { + if (bodyRaw && typeof bodyRaw === 'string' && bodyRaw.trim().length > 0) { + props[index].responseBody.formatted = transform(bodyRaw, 'plaintext') + } else if (bodyRaw && typeof bodyRaw === 'object' && bodyRaw !== null) { + props[index].responseBody.formatted = transform(bodyRaw, 'json') + } } } @@ -151,20 +158,23 @@ export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, props[index].cookies.body = parsedCookie - if (!props[index].requestBody.formatted || !props[index].requestBody.formatted.length) { - props[index].requestBody.formatted = '
(No content)
' - } + props[index].responseHeaders.body = headers - if (!props[index].responseBody.formatted || !props[index].responseBody.formatted.length) { - if (bodyRaw && typeof bodyRaw === 'string' && bodyRaw.trim().length > 0) { - props[index].responseBody.formatted = transform(bodyRaw, 'plaintext') - } else { - props[index].responseBody.formatted = '
(No content)
' + if (render) { + if (!props[index].requestBody.formatted || !props[index].requestBody.formatted.length) { + props[index].requestBody.formatted = '
(No content)
' } - } - props[index].responseHeaders.body = headers - props[index].responseHeaders.formatted = transform(headers) + if (!props[index].responseBody.formatted || !props[index].responseBody.formatted.length) { + if (bodyRaw && typeof bodyRaw === 'string' && bodyRaw.trim().length > 0) { + props[index].responseBody.formatted = transform(bodyRaw, 'plaintext') + } else { + props[index].responseBody.formatted = '
(No content)
' + } + } + + props[index].responseHeaders.formatted = transform(headers) + } let size: number if (contentLengthHeader) { @@ -184,31 +194,49 @@ export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, const yielded = res + const generateCurl = () => { + let curl = `curl -X ${options.method || 'GET'} "${options.url}"`; + if (options.headers) { + Object.entries(options.headers).forEach(([key, value]) => { + curl += ` -H "${key}: ${value}"`; + }); + } + if (options.body) { + if (typeof options.body === 'object') { + curl += ` -d '${JSON.stringify(options.body)}'`; + } else { + curl += ` -d '${options.body}'`; + } + } + return curl; + }; + + // No UI mounted: still log the request (with response/cURL in consoleProps), but skip + // the DOM lookup, snapshot and scroll that only make sense with the rendered UI. + if (!render) { + log.set({ + consoleProps() { + return { + yielded, + cURL: generateCurl() + } + } + }) + + window.props[testId] = props + log.end() + + return cy.wrap(res, { log: false }) + } + const findSnapshotElement = () => { return Cypress.$(`#${props[index].id}`, { log: false }) } - cy.window({ log: false }) + return cy.window({ log: false }) .then(findSnapshotElement) .then(($el) => { - const generateCurl = () => { - let curl = `curl -X ${options.method || 'GET'} "${options.url}"`; - if (options.headers) { - Object.entries(options.headers).forEach(([key, value]) => { - curl += ` -H "${key}: ${value}"`; - }); - } - if (options.body) { - if (typeof options.body === 'object') { - curl += ` -d '${JSON.stringify(options.body)}'`; - } else { - curl += ` -d '${options.body}'`; - } - } - return curl; - }; - log.set({ consoleProps() { return { @@ -226,7 +254,7 @@ export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, doc.getElementById('api-view-bottom')?.scrollIntoView() if (getPluginConfig('snapshotOnly')) { - app.unmount() + app?.unmount() removeStyles() } diff --git a/src/modules/initialize.ts b/src/modules/initialize.ts index 88c2d33..47d3db6 100644 --- a/src/modules/initialize.ts +++ b/src/modules/initialize.ts @@ -1,5 +1,6 @@ import { getState } from '@utils/getState' import { getPluginConfig } from '@utils/pluginConfig' +import { shouldRenderUi } from '@utils/shouldRenderUi' import { RequestProps } from "../types" import { reactive, createApp } from "vue" import App from "../components/App.vue"; @@ -74,6 +75,12 @@ export const initialize = () => { } } + // Skip the expensive Vue app entirely when the UI is disabled (e.g. run mode). + // The request still runs and is logged; only the heavy DOM rendering is avoided. + if (!shouldRenderUi()) { + return { app: null, props } + } + const app = createApp(App, { props: props }) diff --git a/src/types.ts b/src/types.ts index 094defe..5e12922 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,6 +91,13 @@ export interface PluginEnvOptions extends Cypress.ObjectLike { hideCredentials?: boolean hideCredentialsOptions?: HideCredentialsOptions requestMode?: boolean + /** + * Skip rendering the plugin UI (Vue app + syntax highlighting) for performance. + * - unset (default): render in open mode, skip in run mode (`cypress run`/CI) + * - `true`: always skip the UI + * - `false`: always render, even in run mode + */ + disableUi?: boolean } export interface HideCredentialsOptions { diff --git a/src/utils/shouldRenderUi.ts b/src/utils/shouldRenderUi.ts new file mode 100644 index 0000000..9b7f8e4 --- /dev/null +++ b/src/utils/shouldRenderUi.ts @@ -0,0 +1,22 @@ +import { getPluginConfig } from './pluginConfig' + +/** + * Decide whether the plugin UI (Vue app + Prism syntax highlighting) should be rendered. + * + * Rendering the full UI is expensive: every cy.api() call syntax-highlights the entire + * request/response body and mounts it into the DOM, which Test Replay then captures. In + * run mode (`cypress run`/CI) the UI can't be interacted with, so this is pure overhead + * and can crash the browser renderer on large responses (see issue #135). + * + * Behaviour (controlled by the `disableUi` plugin option): + * - `undefined` (default): auto — render in open mode, skip in run mode. + * - `true`: always skip the UI (also helps open-mode performance). + * - `false`: always render, even in run mode (opt back into full Test Replay capture). + */ +export const shouldRenderUi = (): boolean => { + const disableUi = getPluginConfig('disableUi') + if (disableUi === true) return false + if (disableUi === false) return true + // Default: Cypress.config('isInteractive') is true in open mode and false in run mode. + return Cypress.config('isInteractive') !== false +}