feat(GUI): enable test results pane (#7737)

* feat: enable the test result pane

* test: bring back tests and cleanups

* chore: replace tabitem with tabpanel

* chore: useMemo for test result counts

* refactor: abstract RequestTestResultRows as a component
This commit is contained in:
Hexxa
2024-08-05 10:21:37 +08:00
committed by George He
parent 9a6d986e60
commit 734b64f53f
11 changed files with 263 additions and 35 deletions

View File

@@ -12,6 +12,7 @@ export function test(
testCase: msg,
status: 'passed',
executionTime,
category: 'unknown',
});
} catch (e) {
const executionTime = performance.now() - started;
@@ -20,6 +21,7 @@ export function test(
status: 'failed',
executionTime,
errorMessage: `${e}`,
category: 'unknown',
});
}
}
@@ -33,15 +35,18 @@ export function skip(
testCase: msg,
status: 'skipped',
executionTime: 0,
category: 'unknown',
});
}
export type TestStatus = 'passed' | 'failed' | 'skipped';
export type TestCategory = 'unknown' | 'pre-request' | 'after-response';
export interface RequestTestResult {
testCase: string;
status: TestStatus;
executionTime: number; // milliseconds
errorMessage?: string;
category: TestCategory;
}
export interface TestHandler {

View File

@@ -19,20 +19,20 @@ test.describe('after-response script features tests', async () => {
await page.getByLabel('After-response Scripts').click();
});
// test('insomnia.test and insomnia.expect can work together', async ({ page }) => {
// const responsePane = page.getByTestId('response-pane');
test('post: insomnia.test and insomnia.expect can work together', async ({ page }) => {
await page.getByLabel('Request Collection').getByTestId('tests with expect and test').press('Enter');
// await page.getByLabel('Request Collection').getByTestId('tests with expect and test').press('Enter');
// send
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
// // send
// await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
// verify
await page.getByRole('tab', { name: 'Tests' }).click();
// // verify
// await page.getByRole('tab', { name: 'Console' }).click();
// await expect(responsePane).toContainText('✓ happy tests');
// await expect(responsePane).toContainText('✕ unhappy tests: AssertionError: expected 199 to deeply equal 200');
// });
const rows = page.getByTestId('test-result-row');
await expect(rows.first()).toContainText('PASS');
await expect(rows.nth(1)).toContainText('FAIL');
await expect(rows.nth(1)).toContainText('AssertionError:');
});
test('environment and baseEnvironment can be persisted', async ({ page }) => {
const statusTag = page.locator('[data-testid="response-status-tag"]:visible');

View File

@@ -399,20 +399,20 @@ test.describe('pre-request features tests', async () => {
await expect(responsePane).toContainText('fixtures/certificates/fake.pfx'); // original proxy
});
// test('insomnia.test and insomnia.expect can work together ', async ({ page }) => {
// const responsePane = page.getByTestId('response-pane');
test('pre: insomnia.test and insomnia.expect can work together', async ({ page }) => {
await page.getByLabel('Request Collection').getByTestId('insomnia.test').press('Enter');
// await page.getByLabel('Request Collection').getByTestId('insomnia.test').press('Enter');
// send
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
// // send
// await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
// verify
await page.getByRole('tab', { name: 'Tests' }).click();
// // verify
// await page.getByRole('tab', { name: 'Console' }).click();
// await expect(responsePane).toContainText('✓ happy tests');
// await expect(responsePane).toContainText('✕ unhappy tests: AssertionError: expected 199 to deeply equal 200');
// });
const rows = page.getByTestId('test-result-row');
await expect(rows.first()).toContainText('PASS');
await expect(rows.nth(1)).toContainText('FAIL');
await expect(rows.nth(1)).toContainText('AssertionError:');
});
test('environment and baseEnvironment can be persisted', async ({ page }) => {
const statusTag = page.locator('[data-testid="response-status-tag"]:visible');

View File

@@ -56,6 +56,7 @@ test.describe('test hidden window handling', async () => {
expect(await page.locator('.pane-two pre').innerText()).toEqual('Timeout: Running script took too long');
await page.getByRole('tab', { name: 'Console' }).click();
await page.getByRole('tab', { name: 'Preview' }).click();
const windows = await app.windows();
const hiddenWindow = windows[1];
hiddenWindow.close();

View File

@@ -1,4 +1,5 @@
import fs from 'fs';
import type { RequestTestResult } from 'insomnia-sdk';
import { Readable } from 'stream';
import zlib from 'zlib';
@@ -46,6 +47,7 @@ export interface BaseResponse {
// Things from the request
settingStoreCookies: boolean | null;
settingSendCookies: boolean | null;
requestTestResults: RequestTestResult[];
}
export type Response = BaseModel & BaseResponse;
@@ -80,6 +82,7 @@ export function init(): BaseResponse {
// Responses sent before environment filtering will have a special value
// so they don't show up at all when filtering is on.
environmentId: '__LEGACY__',
requestTestResults: [],
};
}

View File

@@ -177,6 +177,7 @@ export const tryToExecutePreRequestScript = async ({
settings,
cookieJar,
globals: activeGlobalEnvironment,
requestTestResults: new Array<RequestTestResult>(),
};
}
const joinedScript = [...folderScripts].join('\n');
@@ -207,6 +208,7 @@ export const tryToExecutePreRequestScript = async ({
settings: mutatedContext.settings || settings,
globals: mutatedContext.globals,
cookieJar: mutatedContext.cookieJar,
requestTestResults: mutatedContext.requestTestResults,
};
};
@@ -411,7 +413,10 @@ export async function tryToExecuteAfterResponseScript(context: RequestAndContext
await fn${i}();
`);
if (folderScripts.length === 0) {
return context;
return {
...context,
requestTestResults: new Array<RequestTestResult>(),
};
}
const joinedScript = [...folderScripts].join('\n');

View File

@@ -37,11 +37,12 @@ export const MockResponseExtractor = () => {
const mimeType = maybeMimeType && isInMockContentTypeList(maybeMimeType) ? maybeMimeType : 'text/plain';
return (
<div className="px-32 h-full flex flex-col justify-center">
<div className="flex place-content-center text-9xl pb-2 text-[--hl-md]">
<div className="flex place-content-center text-9xl pb-8 text-[--hl-md]">
<Icon icon="cube" />
</div>
<div className="flex place-content-center pb-2">
Transform this {getContentTypeName(activeResponse?.contentType) || ''} response to a new mock route or overwrite an existing one.
Transform this
{activeResponse?.contentType ? getContentTypeName(activeResponse?.contentType) === 'Other' ? '' : ` ${getContentTypeName(activeResponse?.contentType)}` : ''} response to a new mock route or overwrite an existing one.
</div>
<form
onSubmit={async e => {
@@ -151,7 +152,7 @@ export const MockResponseExtractor = () => {
setSelectedMockRoute('');
}}
>
<option value="">-- Create new... --</option>
<option value="">-- Create new --</option>
{mockServerAndRoutes
.map(w => (
<option key={w._id} value={w._id}>
@@ -177,7 +178,7 @@ export const MockResponseExtractor = () => {
setSelectedMockRoute(selected);
}}
>
<option value="">-- Create new... --</option>
<option value="">-- Create new --</option>
{mockServerAndRoutes.find(s => s._id === selectedMockServer)?.routes
.map(w => (
<option key={w._id} value={w._id}>

View File

@@ -0,0 +1,157 @@
import crypto from 'crypto';
import type { RequestTestResult } from 'insomnia-sdk';
import React, { type FC, useState } from 'react';
import { Toolbar } from 'react-aria-components';
import { fuzzyMatch } from '../../../common/misc';
type TargetTestType = 'all' | 'passed' | 'failed' | 'skipped';
interface RequestTestResultRowsProps {
requestTestResults: RequestTestResult[];
resultFilter: string;
targetTests: string;
}
const RequestTestResultRows: FC<RequestTestResultRowsProps> = ({
requestTestResults,
resultFilter,
targetTests,
}: RequestTestResultRowsProps) => {
const testResultRows = requestTestResults
.filter(result => {
switch (targetTests) {
case 'all':
return true;
case 'passed':
return result.status === 'passed';
case 'failed':
return result.status === 'failed';
case 'skipped':
return result.status === 'skipped';
default:
throw Error(`unexpected target test type ${targetTests}`);
}
})
.filter(result => {
if (resultFilter.trim() === '') {
return true;
}
return Boolean(fuzzyMatch(
resultFilter,
result.testCase,
{ splitSpace: false, loose: true }
)?.indexes);
})
.map((result, i: number) => {
const key = crypto
.createHash('sha1')
.update(`${result.testCase}"-${i}`)
.digest('hex');
const statusText = {
passed: 'PASS',
failed: 'FAIL',
skipped: 'SKIP',
}[result.status];
const statusTagColor = {
passed: 'bg-lime-600',
failed: 'bg-red-600',
skipped: 'bg-slate-600',
}[result.status];
const executionTime = <span className={result.executionTime < 300 ? 'text-white-500' : 'text-red-500'} >
{result.executionTime === 0 ? '< 0.1' : `${result.executionTime.toFixed(1)}`}
</span>;
const statusTag = <div className={`text-xs rounded p-[2px] inline-block w-16 text-center font-semibold ${statusTagColor}`}>
{statusText}
</div >;
const message = <>
<span className='capitalize'>{result.testCase}</span>
<span className='text-neutral-400'>{result.errorMessage ? ' | ' + result.errorMessage : ''}</span>
</>;
const testCategory = result.category === 'pre-request' ? 'Pre-request Test' :
result.category === 'after-response' ? 'After-response Test' : 'Unknown';
return (
<div key={key} data-testid="test-result-row">
<div className="flex w-full my-3 text-base">
<div className="leading-4 m-auto mx-1">
<span className="mr-2 ml-2" >{statusTag}</span>
</div>
<div className="leading-4 mr-2">
<div className='mr-2 my-1 w-auto text-nowrap'>{message}</div>
<div className='text-sm text-neutral-400 my-1'>{`${testCategory} (`}{executionTime}{' ms)'}</div>
</div>
</div>
</div>);
});
return <>{testResultRows}</>;
};
interface Props {
requestTestResults: RequestTestResult[];
}
export const RequestTestResultPane: FC<Props> = ({
requestTestResults,
}) => {
const [targetTests, setTargetTests] = useState<TargetTestType>('all');
const [resultFilter, setResultFilter] = useState('');
const noTestFoundPage = (
<div className="text-center mt-5">
<div className="">No test results found</div>
<div className="text-sm text-neutral-400">
<b>
<a href="https://docs.insomnia.rest/insomnia/after-response-script">
Add test cases
</a>
</b> using scripting and run the request.
</div>
</div>
);
if (requestTestResults.length === 0) {
return noTestFoundPage;
}
const selectAllTests = () => setTargetTests('all');
const selectPassedTests = () => setTargetTests('passed');
const selectFailedTests = () => setTargetTests('failed');
const selectSkippedTests = () => setTargetTests('skipped');
return <>
<div className='h-full flex flex-col divide-y divide-solid divide-[--hl-md]'>
<div className='h-[calc(100%-var(--line-height-sm))]'>
<Toolbar className="flex items-center h-[--line-height-sm] flex-row text-[var(--font-size-sm)] box-border overflow-x-auto border-solid border-b border-b-[--hl-md]">
<button className="rounded-3xl btn btn--clicky-small mx-1 my-auto" onClick={selectAllTests} >All</button>
<button className="rounded-3xl btn btn--clicky-small mx-1 my-auto" onClick={selectPassedTests} >Passed</button>
<button className="rounded-3xl btn btn--clicky-small mx-1 my-auto" onClick={selectFailedTests} >Failed</button>
<button className="rounded-3xl btn btn--clicky-small mx-1 my-auto" onClick={selectSkippedTests} >Skipped</button>
</Toolbar>
<div className="overflow-y-auto w-auto overflow-x-auto h-[calc(100%-var(--line-height-sm))]">
<RequestTestResultRows
requestTestResults={requestTestResults}
resultFilter={resultFilter}
targetTests={targetTests}
/>
</div>
</div>
<Toolbar className="flex items-center h-[--line-height-sm] flex-shrink-0 flex-row text-[var(--font-size-sm)] box-border overflow-x-auto">
<input
key="test-results-filter"
type="text"
className='flex-1 pl-3'
title="Filter test results"
defaultValue={resultFilter || ''}
placeholder='Filter test results with name'
onChange={e => {
setResultFilter(e.target.value);
}}
/>
</Toolbar>
</div>
</>;
};

View File

@@ -1,6 +1,6 @@
import fs from 'fs';
import { extension as mimeExtension } from 'mime-types';
import React, { type FC, useCallback } from 'react';
import React, { type FC, useCallback, useMemo } from 'react';
import { Tab, TabList, TabPanel, Tabs, Toolbar } from 'react-aria-components';
import { useRouteLoaderData } from 'react-router-dom';
@@ -29,6 +29,7 @@ import { ResponseViewer } from '../viewers/response-viewer';
import { BlankPane } from './blank-pane';
import { Pane, PaneHeader } from './pane';
import { PlaceholderResponsePane } from './placeholder-response-pane';
import { RequestTestResultPane } from './request-test-result-pane';
interface Props {
activeRequestId: string;
@@ -124,6 +125,21 @@ export const ResponsePane: FC<Props> = ({
}
}, [activeRequest, activeResponse]);
const { passedTestCount, totalTestCount } = useMemo(() => {
let passedTestCount = 0;
let totalTestCount = 0;
activeResponse?.requestTestResults.forEach(result => {
if (result.status === 'passed') {
passedTestCount++;
}
totalTestCount++;
});
return { passedTestCount, totalTestCount };
}, [activeResponse]);
const testResultCountTagColor = totalTestCount > 0 ?
passedTestCount === totalTestCount ? 'bg-lime-600' : 'bg-red-600' :
'bg-[var(--hl-sm)]';
if (!activeRequest) {
return <BlankPane type="response" />;
}
@@ -143,6 +159,7 @@ export const ResponsePane: FC<Props> = ({
const timeline = models.response.getTimeline(activeResponse);
const cookieHeaders = getSetCookieHeaders(activeResponse.headers);
return (
<Pane type="response">
{!activeResponse ? null : (
@@ -183,6 +200,22 @@ export const ResponsePane: FC<Props> = ({
<span className="p-2 aspect-square flex items-center justify-between border-solid border border-[--hl-md] overflow-hidden rounded-lg text-xs shadow-small">{cookieHeaders.length}</span>
)}
</Tab>
<Tab
className='flex-shrink-0 h-full flex items-center justify-between cursor-pointer gap-2 outline-none select-none px-3 py-1 text-[--hl] aria-selected:text-[--color-font] hover:bg-[--hl-sm] hover:text-[--color-font] aria-selected:bg-[--hl-xs] aria-selected:focus:bg-[--hl-sm] aria-selected:hover:bg-[--hl-sm] focus:bg-[--hl-sm] transition-colors duration-300'
id='test-results'
>
<div>
<span>
Tests
</span>
<span
className={`rounded-sm ml-1 px-1 ${testResultCountTagColor}`}
style={{ color: 'text-[--hl]' }}
>
{`${passedTestCount} / ${totalTestCount}`}
</span>
</div>
</Tab>
<Tab
className='flex-shrink-0 h-full flex items-center justify-between cursor-pointer gap-2 outline-none select-none px-3 py-1 text-[--hl] aria-selected:text-[--color-font] hover:bg-[--hl-sm] hover:text-[--color-font] aria-selected:bg-[--hl-xs] aria-selected:focus:bg-[--hl-sm] aria-selected:hover:bg-[--hl-sm] focus:bg-[--hl-sm] transition-colors duration-300'
id='mock-response'
@@ -235,14 +268,18 @@ export const ResponsePane: FC<Props> = ({
/>
</ErrorBoundary>
</TabPanel>
<TabPanel
className='w-full flex-1 flex flex-col overflow-y-auto'
id='test-results'
>
<RequestTestResultPane requestTestResults={activeResponse.requestTestResults} />
</TabPanel>
<TabPanel
className='w-full flex-1 flex flex-col overflow-y-auto'
id='mock-response'
>
<MockResponseExtractor />
</TabPanel>
<TabPanel className='w-full flex-1 flex flex-col overflow-y-auto' id='timeline'>
<ErrorBoundary key={activeResponse._id} errorClassName="font-error pad text-center">
<ResponseTimelineViewer

View File

@@ -36,7 +36,7 @@ export const ResponseTimer: FunctionComponent<Props> = ({ handleCancel, activeRe
key={`${activeRequestId}-${record.stepName}`}
className='flex w-full leading-8'
>
<div className='w-3/4 text-left content-center leading-8'>
<div className='w-3/4 ml-1 text-left content-center leading-8'>
<span className="leading-8">
{
record.duration ?
@@ -48,7 +48,9 @@ export const ResponseTimer: FunctionComponent<Props> = ({ handleCancel, activeRe
{record.stepName}
</span>
</div>
{record.duration ? `${((record.duration) / 1000).toFixed(1)} s` : (<MillisecondTimer />)}
<div className='w-1/4 mr-1 text-right leading-8'>
{record.duration ? `${((record.duration) / 1000).toFixed(1)} s` : (<MillisecondTimer />)}
</div>
</div>
))}
</div>

View File

@@ -2,6 +2,7 @@ import { createWriteStream } from 'node:fs';
import path from 'node:path';
import * as contentDisposition from 'content-disposition';
import type { RequestTestResult } from 'insomnia-sdk';
import { extension as mimeExtension } from 'mime-types';
import { type ActionFunction, type LoaderFunction, redirect } from 'react-router-dom';
@@ -421,19 +422,35 @@ export const sendAction: ActionFunction = async ({ request, params }) => {
const requestMeta = await models.requestMeta.getByParentId(requestId);
invariant(requestMeta, 'RequestMeta not found');
const responsePatch = await responseTransform(response, requestData.activeEnvironmentId, renderedRequest, renderedResult.context);
const is2XXWithBodyPath = responsePatch.statusCode && responsePatch.statusCode >= 200 && responsePatch.statusCode < 300 && responsePatch.bodyPath;
const baseResponsePatch = await responseTransform(response, requestData.activeEnvironmentId, renderedRequest, renderedResult.context);
const is2XXWithBodyPath = baseResponsePatch.statusCode && baseResponsePatch.statusCode >= 200 && baseResponsePatch.statusCode < 300 && baseResponsePatch.bodyPath;
const shouldWriteToFile = shouldPromptForPathAfterResponse && is2XXWithBodyPath;
mutatedContext.request.afterResponseScript = afterResponseScript;
window.main.addExecutionStep({ requestId, stepName: 'Executing after-response script' });
await tryToExecuteAfterResponseScript({
const postMutatedContext = await tryToExecuteAfterResponseScript({
...requestData,
...mutatedContext,
response,
});
window.main.completeExecutionStep({ requestId });
const responsePatch = postMutatedContext ?
{
...baseResponsePatch,
// both pre-request and after-response test results are collected
requestTestResults: [
...mutatedContext.requestTestResults.map(
(result: RequestTestResult): RequestTestResult => ({ ...result, category: 'pre-request' }),
),
...postMutatedContext.requestTestResults.map(
(result: RequestTestResult): RequestTestResult => ({ ...result, category: 'after-response' }),
),
],
}
: baseResponsePatch;
if (!shouldWriteToFile) {
const response = await models.response.create(responsePatch, requestData.settings.maxHistoryResponses);
await models.requestMeta.update(requestMeta, { activeResponseId: response._id });