Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
41 changes: 41 additions & 0 deletions cypress/e2e/disableUi.cy.ts
Original file line number Diff line number Diff line change
@@ -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')
})

})
4 changes: 3 additions & 1 deletion src/modules/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})

Expand All @@ -17,7 +18,8 @@ export const api = (...params: Partial<ApiRequestOptions>[]) => {
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))
}
140 changes: 84 additions & 56 deletions src/modules/handleResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element>) => {
export const handleResponse = (res: ApiResponseBody, options: ApiRequestOptions, props: RequestProps[], index: number, app: App<Element> | null) => {

const { doc, testId } = getState()
const render = shouldRenderUi()

if (!props[index].url || props[index].url === '') {
const baseUrl = Cypress.config('baseUrl')
Expand All @@ -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

Expand Down Expand Up @@ -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')
}
}
}

Expand All @@ -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 = '<div class="pl-4 text-cy-gray text-xs font-mono">(No content)</div>'
}
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 = '<div class="pl-4 text-cy-gray text-xs font-mono">(No content)</div>'
if (render) {
if (!props[index].requestBody.formatted || !props[index].requestBody.formatted.length) {
props[index].requestBody.formatted = '<div class="pl-4 text-cy-gray text-xs font-mono">(No content)</div>'
}
}

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 = '<div class="pl-4 text-cy-gray text-xs font-mono">(No content)</div>'
}
}

props[index].responseHeaders.formatted = transform(headers)
}

let size: number
if (contentLengthHeader) {
Expand All @@ -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;
};
Comment on lines +197 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

5. Unsafe duplicate curl builder 🧑 Team insight ⛨ Security

**Inspired by avrik-berman's coding and review patterns** — handleResponse() reintroduces a local
generateCurl() that doesn’t escape quotes/newlines and bypasses the existing shared helper,
risking malformed commands and credential leakage (especially when hideCredentials is enabled).
This also duplicates logic already centralized in src/utils/generateCurl.ts.
Agent Prompt
### Issue description
`src/modules/handleResponse.ts` defines a local `generateCurl()` that (a) duplicates existing functionality and (b) builds a cURL string without escaping/quoting safeguards. It also uses `options.*` rather than the (potentially anonymized) `props[index]`, which can leak sensitive values when `hideCredentials` is enabled.

### Issue Context
A shared `generateCurl(item: RequestProps)` already exists and includes escaping for quotes/newlines and composes auth/query/headers/body consistently.

### Fix Focus Areas
- src/modules/handleResponse.ts[195-246]
- src/utils/generateCurl.ts[1-75]
- src/modules/api.ts[15-24]

### Expected change
- Remove the local `generateCurl` closure from `handleResponse()`.
- Import `generateCurl` from `@utils/generateCurl` and use `generateCurl(props[index])` in both render and non-render consoleProps paths.
- Ensure the generated cURL reflects anonymized props when `hideCredentials` is on (already achieved by using `props[index]`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// 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 {
Expand All @@ -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()
}

Expand Down
7 changes: 7 additions & 0 deletions src/modules/initialize.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
})
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions src/utils/shouldRenderUi.ts
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +17 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. disableui: false enables run-mode ui 📎 Requirement gap ➹ Performance

shouldRenderUi() allows forcing full UI rendering in Cypress run mode when disableUi is
explicitly set to false, which defeats the run-mode/CI safety requirement and can reintroduce
renderer crashes/perf issues. The repo also configures disableUi: false in cypress.config.ts,
causing UI rendering under cypress run.
Agent Prompt
## Issue description
Compliance requires that the plugin must not render the full UI/DOM output in Cypress run mode / CI. The new `disableUi` logic currently allows `disableUi: false` to force rendering even in run mode, and the repo’s `cypress.config.ts` sets `disableUi: false`, which will render UI during `cypress run`.

## Issue Context
The intended performance safeguard for CI/run mode should be unconditional per the compliance rule; users can still use UI in open mode, and can still disable UI in any mode.

## Fix Focus Areas
- src/utils/shouldRenderUi.ts[17-21]
- cypress.config.ts[9-11]
- README.md[80-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
Loading