mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
test(platform): Add a script to automatically export the E2E test request and responses
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5386 GitOrigin-RevId: 5458dadc7a23f82a1b344fcd19d8c431d6bd8c78
This commit is contained in:
parent
d5936a0a9b
commit
e8cb480b13
@ -1,5 +1,7 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
import * as customTasks from './cypress/support/tasks';
|
||||
|
||||
export default defineConfig({
|
||||
env: {
|
||||
BASE_URL: 'http://localhost:3000',
|
||||
@ -15,9 +17,16 @@ export default defineConfig({
|
||||
// We've imported your old cypress plugins here.
|
||||
// You may want to clean this up later by importing these.
|
||||
setupNodeEvents(on, config) {
|
||||
return require('./cypress/plugins/index.js')(on, config);
|
||||
on('task', {
|
||||
...customTasks,
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
baseUrl: 'http://localhost:3000',
|
||||
specPattern: 'cypress/e2e/**/*test.{js,jsx,ts,tsx}',
|
||||
specPattern: [
|
||||
'cypress/e2e/**/*test.{js,jsx,ts,tsx}',
|
||||
'cypress/support/**/*unit.test.{js,ts}',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
import * as customTasks from './cypress/support/tasks';
|
||||
|
||||
export default defineConfig({
|
||||
env: {
|
||||
BASE_URL: 'http://localhost:3000',
|
||||
@ -17,9 +19,16 @@ export default defineConfig({
|
||||
// We've imported your old cypress plugins here.
|
||||
// You may want to clean this up later by importing these.
|
||||
setupNodeEvents(on, config) {
|
||||
return require('./cypress/plugins/index.js')(on, config);
|
||||
on('task', {
|
||||
...customTasks,
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
baseUrl: 'http://localhost:3000',
|
||||
specPattern: 'cypress/e2e/**/*test.{js,jsx,ts,tsx}',
|
||||
specPattern: [
|
||||
'cypress/e2e/**/*test.{js,jsx,ts,tsx}',
|
||||
'cypress/support/**/*unit.test.{js,ts}',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
@ -1,17 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
module.exports = () => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
};
|
@ -29,7 +29,8 @@ import '@testing-library/cypress/add-commands';
|
||||
|
||||
import './visitEmptyPage';
|
||||
import './clearConsoleTextarea';
|
||||
import './notifications'
|
||||
import './notifications';
|
||||
import './contractIntercept';
|
||||
|
||||
Cypress.Commands.add('getBySel', (selector, ...args) => {
|
||||
return cy.get(`[data-test=${selector}]`, ...args);
|
||||
|
168
console/cypress/support/contractIntercept/contractIntercept.ts
Normal file
168
console/cypress/support/contractIntercept/contractIntercept.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import type {
|
||||
ContractRequest,
|
||||
RunningTestsState,
|
||||
StartContractInterceptOptions,
|
||||
} from './types';
|
||||
|
||||
import { checkAndGetTestInfo } from './helpers/checkAndGetTestInfo';
|
||||
import { generateEmptyTestState } from './helpers/generateEmptyTestState';
|
||||
|
||||
let runningTestState: RunningTestsState = {};
|
||||
|
||||
/**
|
||||
* A wrapper around `cy.intercept` that allows intercepting and recording the request/response series
|
||||
* that made up the contract.
|
||||
*
|
||||
* It could be useful for sharing the contract's fixtures with the server folks.
|
||||
*
|
||||
* Be aware of the current limitations:
|
||||
* 1. Only one test at the time is supported. `runningTestState` can store more tests at once but
|
||||
* the fixture files are all saved starting from "1 - ", even if related to different tests
|
||||
* 2. If you do not call cy.haltContractIntercept, no fixture files will be saved
|
||||
*
|
||||
* @see https://github.com/hasura/graphql-engine-mono/issues/4601
|
||||
*/
|
||||
function startContractIntercept(
|
||||
startContractInterceptOptions: StartContractInterceptOptions,
|
||||
url: string
|
||||
) {
|
||||
const { thisTest, mode, createFixtureName } = startContractInterceptOptions;
|
||||
const { testTitle, testPath } = checkAndGetTestInfo(thisTest);
|
||||
|
||||
if (mode === 'disabled') {
|
||||
Cypress.log({
|
||||
message: `*🤝 ❌ No Contract will be recorded for ${testTitle}*`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Cypress.log({
|
||||
message: `*🤝 ✅ Contract will be recorded for ${testTitle}*`,
|
||||
});
|
||||
|
||||
runningTestState[testTitle] ??= generateEmptyTestState(testPath, testTitle);
|
||||
|
||||
if (Object.keys(runningTestState).length > 1) {
|
||||
throw new Error(`startContractIntercept support only one test at a time`);
|
||||
}
|
||||
// Start intercepting the requests
|
||||
cy.intercept(url, request => {
|
||||
// The recorded could have been halted
|
||||
if (runningTestState[testTitle].halted) {
|
||||
Cypress.log({
|
||||
message: `*🤝 ❌ Contract recording has been halted for: ${testTitle}*`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fixtureName = createFixtureName(request);
|
||||
if (fixtureName.includes('\\') || fixtureName.includes('/')) {
|
||||
throw new Error(
|
||||
`createFixtureName cannot return names that includes / or \ like ${fixtureName}`
|
||||
);
|
||||
}
|
||||
|
||||
const contractLength = runningTestState[testTitle].contract.length;
|
||||
|
||||
// start from 1
|
||||
const fixtureIndex = contractLength + 1;
|
||||
const fixtureFileName = `${fixtureIndex}-${fixtureName}.json`;
|
||||
|
||||
const recorded: ContractRequest = {
|
||||
readme:
|
||||
'////////// This fixture has been automatically generated through cy.startContractIntercept //////////',
|
||||
request,
|
||||
fixtureName,
|
||||
fixtureFileName,
|
||||
|
||||
// Temporary, empty, response
|
||||
response: {
|
||||
statusCode: undefined,
|
||||
headers: undefined,
|
||||
body: undefined,
|
||||
},
|
||||
};
|
||||
// Add the request to the Contract
|
||||
runningTestState[testTitle].contract.push(recorded);
|
||||
|
||||
Cypress.log({
|
||||
message: `*🤝 ✅ Recorded ${fixtureFileName} in the contract*`,
|
||||
consoleProps: () => request,
|
||||
});
|
||||
|
||||
request.continue(response => {
|
||||
// Add the request to the Contract too
|
||||
recorded.response = response;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Halt recording the contract and save the fixture files.
|
||||
* Please note that it must be called just once
|
||||
*/
|
||||
function haltContractIntercept(options: {
|
||||
thisTest: Mocha.Context;
|
||||
saveFixtureFiles?: boolean;
|
||||
}) {
|
||||
const { thisTest, saveFixtureFiles = true } = options;
|
||||
const { testTitle, testPath } = checkAndGetTestInfo(thisTest);
|
||||
|
||||
if (!saveFixtureFiles) {
|
||||
Cypress.log({
|
||||
message: `*🤝 ❌ No fixtures will be saved for this test: ${testTitle}*`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (runningTestState[testTitle].halted) {
|
||||
Cypress.log({
|
||||
message: `*🤝 ❌ Contract recording for this test has already been halted: ${testTitle}*`,
|
||||
});
|
||||
}
|
||||
|
||||
// Halt recording the requests for the current test.
|
||||
// Please note that must be done asynchronously because of the double-run nature of the Cypress tests.
|
||||
cy.wrap(null).then(() => {
|
||||
Cypress.log({
|
||||
message: `*🤝 ❌ Halting the contract recording for this test: ${testTitle}*`,
|
||||
});
|
||||
runningTestState[testTitle].halted = true;
|
||||
});
|
||||
|
||||
// Split the current path
|
||||
cy.task('splitPath', { path: testPath }).then(result => {
|
||||
const splittedPath = result as string[];
|
||||
|
||||
// Remove the file name
|
||||
splittedPath.pop();
|
||||
|
||||
// Create the directory
|
||||
cy.task('joinPath', { path: [...splittedPath, 'fixtures'] }).then(path => {
|
||||
cy.task('mkdirSync', {
|
||||
dir: path as string,
|
||||
});
|
||||
});
|
||||
|
||||
const testState = runningTestState[testTitle];
|
||||
|
||||
// Save all the files
|
||||
for (let i = 0, n = testState.contract.length; i < n; i++) {
|
||||
const request = testState.contract[i];
|
||||
|
||||
cy.task('joinPath', {
|
||||
// Stores the fixture files close to the test file, in a "fixtures" directory
|
||||
path: [...splittedPath, 'fixtures', request.fixtureFileName],
|
||||
}).then(filePath => {
|
||||
// Save the fixture file
|
||||
cy.task('writeFileSync', {
|
||||
file: filePath as string,
|
||||
data: JSON.stringify(request, null, 2),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Cypress.Commands.add('startContractIntercept', startContractIntercept);
|
||||
Cypress.Commands.add('haltContractIntercept', haltContractIntercept);
|
@ -0,0 +1,24 @@
|
||||
import { generateTestTitle } from './generateTestTitle';
|
||||
import { throwIfCalledInTestHooks } from './throwIfCalledInTestHooks';
|
||||
import { throwIfCalledInsideArrowFunction } from './throwIfCalledInsideArrowFunction';
|
||||
|
||||
/**
|
||||
* Perform pre-flight checks and return the most important test info.
|
||||
*/
|
||||
export function checkAndGetTestInfo(thisTest: Mocha.Context) {
|
||||
const testTitle = generateTestTitle(thisTest); // ex. 'Describe 1 - Describe 2 - Describe 3 - Test title'
|
||||
const testPath = thisTest?.invocationDetails?.relativeFile;
|
||||
|
||||
throwIfCalledInTestHooks(thisTest.title);
|
||||
throwIfCalledInsideArrowFunction(thisTest.title);
|
||||
|
||||
// TS-only check, it should never happen at runtime
|
||||
if (typeof testPath !== 'string') {
|
||||
throw new Error(`No test Path available for ${testTitle}`);
|
||||
}
|
||||
|
||||
return {
|
||||
testPath,
|
||||
testTitle,
|
||||
};
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Return an array containing all the "describe"s title.
|
||||
*
|
||||
* Ex. return ['Describe 1', 'Describe 2'] in this case
|
||||
* describe('Describe 1', () => {
|
||||
* describe('Describe 2', () => {
|
||||
* it('Test', () => {})
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
export function generateDescribesTitle(
|
||||
parent?: Mocha.Suite | undefined
|
||||
): string[] {
|
||||
if (!parent) return [];
|
||||
|
||||
let parentNames: string[] = [];
|
||||
// parents are recursive
|
||||
if (parent.parent) {
|
||||
parentNames = parentNames.concat(generateDescribesTitle(parent.parent));
|
||||
}
|
||||
|
||||
if (parent.title !== '') {
|
||||
parentNames.push(parent.title);
|
||||
}
|
||||
|
||||
return parentNames;
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
import { generateDescribesTitle } from './generateDescribesTitle';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// TYPES -------------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
// This is a simplified version of Mocha.Suite
|
||||
type SimplifiedTestRoot = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type SimplifiedTestOrDescribe = {
|
||||
parent: SimplifiedTestRoot | SimplifiedTestOrDescribe;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type SimplifiedTestSuite = SimplifiedTestOrDescribe | SimplifiedTestRoot;
|
||||
// -------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('generateDescribesTitle', () => {
|
||||
it('Should return an array including all the titles of the chain of test.describe and the test itself', () => {
|
||||
// Arrange
|
||||
// In case of a test file like the following
|
||||
// describe('Describe 1', () => {
|
||||
// describe('Describe 2', () => {
|
||||
// describe('Describe 3', () => {
|
||||
// it('Test title', () => {})
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
const testSuite: SimplifiedTestSuite = {
|
||||
parent: {
|
||||
parent: {
|
||||
parent: {
|
||||
title: 'Describe 1',
|
||||
},
|
||||
title: 'Describe 2',
|
||||
},
|
||||
title: 'Describe 3',
|
||||
},
|
||||
title: 'Test title',
|
||||
};
|
||||
|
||||
// Act
|
||||
// testSuite is compatible for what concerns to generateDescribesTitle that will only look for the
|
||||
// `parent` and `title` properties
|
||||
const result = generateDescribesTitle(testSuite.parent as Mocha.Suite);
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['Describe 1', 'Describe 2', 'Describe 3']);
|
||||
});
|
||||
|
||||
it('Should return an array including only the test title in case of no describes', () => {
|
||||
// Act
|
||||
// testSuite is compatible for what concerns to generateDescribesTitle that will only look for the
|
||||
// `parent` and `title` properties
|
||||
const result = generateDescribesTitle(undefined);
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('Should return an array including all the titles of the chain of test.describe and the test hook itself', () => {
|
||||
// Arrange
|
||||
// In case of a test file like the following
|
||||
// describe('Describe 1', () => {
|
||||
// before(() => {})
|
||||
// it('Test title', () => {})
|
||||
// })
|
||||
const testSuite: SimplifiedTestSuite = {
|
||||
parent: {
|
||||
title: 'Describe 1',
|
||||
},
|
||||
title: '"before" hook',
|
||||
};
|
||||
|
||||
// Act
|
||||
// testSuite is compatible for what concerns to generateDescribesTitle that will only look for the
|
||||
// `parent` and `title` properties
|
||||
const result = generateDescribesTitle(testSuite.parent as Mocha.Suite);
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['Describe 1']);
|
||||
});
|
||||
});
|
@ -0,0 +1,13 @@
|
||||
import type { RunningTestState } from '../types';
|
||||
|
||||
export function generateEmptyTestState(
|
||||
testPath: string,
|
||||
testTitle: string
|
||||
): RunningTestState {
|
||||
return {
|
||||
testPath,
|
||||
testTitle,
|
||||
halted: false,
|
||||
contract: [],
|
||||
};
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import type { TestTitle } from '../types';
|
||||
import { generateDescribesTitle } from './generateDescribesTitle';
|
||||
|
||||
/**
|
||||
* Return a string containing all the "describe"s titles and the test title concatenated.
|
||||
*
|
||||
* Ex. return 'Describe 1 - Describe 2 - Test' in this case
|
||||
* describe('Describe 1', () => {
|
||||
* describe('Describe 2', () => {
|
||||
* it('Test', () => {})
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
export function generateTestTitle(thisTest: Mocha.Context): TestTitle {
|
||||
const describesTitle = generateDescribesTitle(thisTest.parent);
|
||||
const testTitle = thisTest.title;
|
||||
|
||||
return [...describesTitle, testTitle].join(' - ');
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
import { generateTestTitle } from './generateTestTitle';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// TYPES -------------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
// This is a simplified version of Mocha.Suite
|
||||
type SimplifiedTestRoot = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type SimplifiedTestOrDescribe = {
|
||||
parent: SimplifiedTestRoot | SimplifiedTestOrDescribe;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type SimplifiedTestSuite = SimplifiedTestOrDescribe;
|
||||
// -------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('generateTestTitle', () => {
|
||||
it('Should return an array including all the titles of the chain of test.describe and the test itself', () => {
|
||||
// Arrange
|
||||
// In case of a test file like the following
|
||||
// describe('Describe 1', () => {
|
||||
// describe('Describe 2', () => {
|
||||
// describe('Describe 3', () => {
|
||||
// it('Test title', () => {})
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
const testSuite: SimplifiedTestSuite = {
|
||||
parent: {
|
||||
parent: {
|
||||
parent: {
|
||||
title: 'Describe 1',
|
||||
},
|
||||
title: 'Describe 2',
|
||||
},
|
||||
title: 'Describe 3',
|
||||
},
|
||||
title: 'Test title',
|
||||
};
|
||||
|
||||
// Act
|
||||
// testSuite is compatible for what concerns to generateTestTitle that will only look for the
|
||||
// `parent` and `title` properties
|
||||
const result = generateTestTitle((testSuite as unknown) as Mocha.Context);
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(
|
||||
'Describe 1 - Describe 2 - Describe 3 - Test title'
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,18 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
describe(`cy.task('splitPath')`, () => {
|
||||
it('Should split the passed path', () => {
|
||||
const path =
|
||||
'cypress/support/interceptAndRecordContract/helpers/temp.unit.test.ts';
|
||||
|
||||
cy.task('splitPath', { path }).then(result => {
|
||||
expect(result).to.deep.equal([
|
||||
'cypress',
|
||||
'support',
|
||||
'interceptAndRecordContract',
|
||||
'helpers',
|
||||
'temp.unit.test.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Throws if called from inside a test hook.
|
||||
* The problem is that it's impossible to know which test the hook relates to. Hence, it's
|
||||
* impossible to detect the name of the test.
|
||||
*/
|
||||
export function throwIfCalledInTestHooks(testTitle: string) {
|
||||
switch (testTitle) {
|
||||
case '"after" hook':
|
||||
throw new Error(
|
||||
'interceptAndRecordContract cannot be called inside a "after" hook'
|
||||
);
|
||||
case '"after all" hook':
|
||||
throw new Error(
|
||||
'interceptAndRecordContract cannot be called inside a "after all" hook'
|
||||
);
|
||||
case '"before" hook':
|
||||
throw new Error(
|
||||
'interceptAndRecordContract cannot be called inside a "before" hook'
|
||||
);
|
||||
case '"before all" hook':
|
||||
throw new Error(
|
||||
'interceptAndRecordContract cannot be called inside a "before all" hook'
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
import { throwIfCalledInTestHooks } from './throwIfCalledInTestHooks';
|
||||
|
||||
describe('throwIfCalledInTestHooks', () => {
|
||||
it('Should throw an error if called with "after" hook', () => {
|
||||
// Arrange
|
||||
const executor = () => throwIfCalledInTestHooks('"after" hook');
|
||||
|
||||
// Assert
|
||||
expect(executor).to.throw(
|
||||
'interceptAndRecordContract cannot be called inside a "after" hook'
|
||||
);
|
||||
});
|
||||
|
||||
it('Should throw an error if called with "after all" hook', () => {
|
||||
// Arrange
|
||||
const executor = () => throwIfCalledInTestHooks('"after all" hook');
|
||||
|
||||
// Assert
|
||||
expect(executor).to.throw(
|
||||
'interceptAndRecordContract cannot be called inside a "after all" hook'
|
||||
);
|
||||
});
|
||||
|
||||
it('Should throw an error if called with "before" hook', () => {
|
||||
// Arrange
|
||||
const executor = () => throwIfCalledInTestHooks('"before" hook');
|
||||
|
||||
// Assert
|
||||
expect(executor).to.throw(
|
||||
'interceptAndRecordContract cannot be called inside a "before" hook'
|
||||
);
|
||||
});
|
||||
|
||||
it('Should throw an error if called with "before all" hook', () => {
|
||||
// Arrange
|
||||
const executor = () => throwIfCalledInTestHooks('"before all" hook');
|
||||
|
||||
// Assert
|
||||
expect(executor).to.throw(
|
||||
'interceptAndRecordContract cannot be called inside a "before all" hook'
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not throw an error if called with another test title', () => {
|
||||
// Arrange
|
||||
const executor = () => throwIfCalledInTestHooks('Test title');
|
||||
|
||||
// Assert
|
||||
expect(executor).not.to.throw(
|
||||
'interceptAndRecordContract cannot be called inside a "before all" hook'
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Throw if interceptAndRecordContract has been called without the `this.test` element.
|
||||
*
|
||||
* It happens when the test is called inside an arrow function
|
||||
* ex.
|
||||
* it('...', () => { // <- THIS is the problem
|
||||
* cy.startContractIntercept({
|
||||
* thisTest: this.test,
|
||||
* // ...
|
||||
*/
|
||||
export function throwIfCalledInsideArrowFunction(thisTest: Mocha.Context | {}) {
|
||||
if (Object.keys(thisTest).length === 0) {
|
||||
throw new Error(
|
||||
'interceptAndRecordContract did not receive `this` that refers to the test itself. Have you called interceptAndRecordContract inside an arrow function? If yes, transform function into a regular one'
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
import { throwIfCalledInsideArrowFunction } from './throwIfCalledInsideArrowFunction';
|
||||
|
||||
describe('throwIfCalledInsideArrowFunction', () => {
|
||||
it('Should throw an error if called without a the reference to the `this.test`', () => {
|
||||
// Arrange
|
||||
const executor = () => throwIfCalledInsideArrowFunction({});
|
||||
|
||||
// Assert
|
||||
expect(executor).to.throw(
|
||||
'interceptAndRecordContract did not receive `this` that refers to the test itself. Have you called interceptAndRecordContract inside an arrow function? If yes, transform function into a regular one'
|
||||
);
|
||||
});
|
||||
});
|
1
console/cypress/support/contractIntercept/index.ts
Normal file
1
console/cypress/support/contractIntercept/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './contractIntercept';
|
67
console/cypress/support/contractIntercept/types.ts
Normal file
67
console/cypress/support/contractIntercept/types.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { CyHttpMessages } from 'cypress/types/net-stubbing';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// PACT TYPES --------------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Borrowed from Pact's types https://github.com/pactflow/pact-cypress-adapter/blob/main/src/types.ts
|
||||
export type HeaderType = Record<string, string | string[]> | undefined;
|
||||
|
||||
type BaseXHR = {
|
||||
headers: HeaderType;
|
||||
body: any | undefined;
|
||||
};
|
||||
|
||||
export type XHRRequestAndResponse = {
|
||||
request: {
|
||||
method: string;
|
||||
url: string;
|
||||
} & BaseXHR;
|
||||
response: {
|
||||
statusCode: string | number | undefined;
|
||||
// statusText: string | undefined;
|
||||
} & BaseXHR;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// CONTRACT RECORDING TYPES ------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export type TestTitle = string;
|
||||
type TestPath = string;
|
||||
|
||||
export type ContractRequest = XHRRequestAndResponse & {
|
||||
readme: string;
|
||||
fixtureName: string;
|
||||
fixtureFileName: string;
|
||||
};
|
||||
|
||||
export type RunningTestState = {
|
||||
testTitle: TestTitle;
|
||||
testPath: TestPath;
|
||||
halted: boolean;
|
||||
|
||||
contract: ContractRequest[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Considering the current limitation of "only one test at a time can be recorded", the whole state
|
||||
* and startContractIntercept could be simplified a lot by:
|
||||
* 1. Transforming the State into a Map<Mocha.Context, RunningTestState>
|
||||
* 2. Using the test instance, instead of the test name, to store the test state
|
||||
* 3. Getting read of generateTestTitle and its tests
|
||||
*
|
||||
* Please note that this would result also in not having the test name in the cypress logs but this
|
||||
* is not important since only a single test can be recorded at a time...
|
||||
*/
|
||||
export type RunningTestsState = Record<TestTitle, RunningTestState>;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// OPTIONS TYPES -----------------------------------------------------
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export type StartContractInterceptOptions = {
|
||||
thisTest: Mocha.Context;
|
||||
mode: 'record' | 'disabled';
|
||||
createFixtureName: (req: CyHttpMessages.IncomingHttpRequest) => string;
|
||||
};
|
43
console/cypress/support/index.d.ts
vendored
43
console/cypress/support/index.d.ts
vendored
@ -8,23 +8,66 @@ declare namespace Cypress {
|
||||
* @example cy.getBySel('greeting')
|
||||
*/
|
||||
getBySel(value: string): Chainable<JQuery<Element>>;
|
||||
|
||||
/**
|
||||
* Custom command to work around the fact that cy.clear sometimes fails at clearing the
|
||||
* Console's textarea
|
||||
* @example cy.get('textarea').clearConsoleTextarea()
|
||||
*/
|
||||
clearConsoleTextarea(): Chainable<JQuery<HTMLTextAreaElement>>;
|
||||
|
||||
/**
|
||||
* Visit the initial empty page.
|
||||
* Console's textarea
|
||||
* @example cy.visitEmptyPage()
|
||||
*/
|
||||
visitEmptyPage(): Chainable<unknown>;
|
||||
|
||||
/**
|
||||
* Success notifications
|
||||
*/
|
||||
expectSuccessNotification(): Chainable<unknown>;
|
||||
expectSuccessNotificationWithTitle(title: string): Chainable<unknown>;
|
||||
expectSuccessNotificationWithMessage(message: string): Chainable<unknown>;
|
||||
|
||||
/**
|
||||
* Error notifications
|
||||
*/
|
||||
expectErrorNotification(): Chainable<unknown>;
|
||||
expectErrorNotificationWithTitle(title: string): Chainable<unknown>;
|
||||
expectErrorNotificationWithMessage(message: string): Chainable<unknown>;
|
||||
|
||||
/**
|
||||
* Start intercepting the request/response contract between the Console and the server.
|
||||
* @example
|
||||
* cy.startContractIntercept(
|
||||
* {
|
||||
* thisTest: this.test,
|
||||
* mode: 'record',
|
||||
* createFixtureName: (req: CyHttpMessages.IncomingHttpRequest) => {
|
||||
* if (req.url.endsWith('v1/metadata')) {
|
||||
* return `v1-metadata-${req.body.type}`;
|
||||
* }
|
||||
*
|
||||
* throw new Error(`Unknown url ${req.url}`);
|
||||
* },
|
||||
* },
|
||||
* 'http://localhost:8080/**'
|
||||
* );
|
||||
*/
|
||||
startContractIntercept(
|
||||
startContractInterceptOptions: import('./contractIntercept/types').StartContractInterceptOptions,
|
||||
url: string
|
||||
): Chainable<unknown>;
|
||||
|
||||
/**
|
||||
* Halt intercepting the request/response contract between the Console and the server and save the fixtures.
|
||||
* @example
|
||||
* cy.haltContractIntercept({ thisTest: this.test })
|
||||
*/
|
||||
haltContractIntercept(options: {
|
||||
thisTest: Mocha.Context;
|
||||
saveFixtureFiles?: boolean;
|
||||
}): Chainable<unknown>;
|
||||
}
|
||||
}
|
||||
|
4
console/cypress/support/tasks/index.ts
Normal file
4
console/cypress/support/tasks/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { joinPath } from './joinPath';
|
||||
export { mkdirSync } from './mkdirSync';
|
||||
export { splitPath } from './splitPath';
|
||||
export { writeFileSync } from './writeFileSync';
|
12
console/cypress/support/tasks/joinPath.ts
Normal file
12
console/cypress/support/tasks/joinPath.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import nodePath from 'node:path';
|
||||
|
||||
interface Options {
|
||||
path: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the given path using the OS-based separator.
|
||||
*/
|
||||
export function joinPath(options: Options) {
|
||||
return options.path.join(nodePath.sep);
|
||||
}
|
16
console/cypress/support/tasks/mkdirSync.ts
Normal file
16
console/cypress/support/tasks/mkdirSync.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import fs from 'fs';
|
||||
|
||||
interface Options {
|
||||
dir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper task around mkdirSync.
|
||||
*/
|
||||
export function mkdirSync(options: Options) {
|
||||
if (fs.existsSync(options.dir)) return null;
|
||||
|
||||
fs.mkdirSync(options.dir);
|
||||
|
||||
return null;
|
||||
}
|
12
console/cypress/support/tasks/splitPath.ts
Normal file
12
console/cypress/support/tasks/splitPath.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import nodePath from 'node:path';
|
||||
|
||||
interface Options {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the given path using the OS-based separator.
|
||||
*/
|
||||
export function splitPath(options: Options) {
|
||||
return options.path.split(nodePath.sep);
|
||||
}
|
15
console/cypress/support/tasks/writeFileSync.ts
Normal file
15
console/cypress/support/tasks/writeFileSync.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import fs from 'fs';
|
||||
|
||||
interface Options {
|
||||
file: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper task around writeFileSync.
|
||||
*/
|
||||
export function writeFileSync(options: Options) {
|
||||
fs.writeFileSync(options.file, options.data);
|
||||
|
||||
return null;
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
import { Meta } from '@storybook/addon-docs';
|
||||
|
||||
<Meta title="Dev/Testing/6. Export the Contract (request and responses) from an E2E test" />
|
||||
|
||||
# Export the Contract (request/responses) from an E2E test
|
||||
|
||||
During 2022, the need to reduce the number of E2E tests led us to move to a Contract Testing approach ([here is the related issue](https://github.com/hasura/graphql-engine-mono/issues/4601)). The goal of this document is to list the steps to do to export a contract out of an E2E test in order to simplify the server folks to replace the confidence the E2E tests give with a "contract" test.
|
||||
|
||||
Limitations:
|
||||
|
||||
1. Only one test at a time can export the fixtures
|
||||
|
||||
2. The order of the fixtures can change at every run of the E2E test because the Console perform requests without a fixed order
|
||||
|
||||
3. `cy.startContractIntercept` cannot be used as a way to help generating integration tests ot of E2E ones because of the bad DX offered by this approach, such as
|
||||
|
||||
- the exported JSONs do not include only the response as Cypress expects from the fixture files
|
||||
- the requests performed during a full CRUD E2E tests are too much
|
||||
|
||||
Please consider that the problem is strictly related to migrating the E2E tests to integration ones. When we are creating new features, the integration tests are smaller (that also means less requests), and we should have the typed mocks from the Storybook stories of the involved components that could also be imported in the Cypress tests.
|
||||
|
||||
4. The fixtures as they are do not leverage any kind of [Pact feature](https://github.com/pact-foundation/pact-specification/tree/version-3)
|
||||
|
||||
## Step 1: moving the test to using classif functions instead of arrow functions
|
||||
|
||||
```diff
|
||||
-it('When the users create, edit, and delete a Query Action, everything should work', () => {
|
||||
+it('When the users create, edit, and delete a Query Action, everything should work', function () {
|
||||
```
|
||||
|
||||
## Step 2: moving the visit call inside the test instead of inside the test hooks
|
||||
|
||||
```diff
|
||||
before(() => {
|
||||
- cy.visit('/actions/manage/actions');
|
||||
});
|
||||
|
||||
it('When the users create, edit, and delete a Query Action, everything should work', function () {
|
||||
+ cy.visit('/actions/manage/actions');
|
||||
```
|
||||
|
||||
## Step 3: leverage cy.startContractIntercept
|
||||
|
||||
```diff
|
||||
it('When the users create, edit, and delete a Query Action, everything should work', function () {
|
||||
+ cy.startContractIntercept(
|
||||
+ {
|
||||
+ thisTest: this.test,
|
||||
+ mode: 'record',
|
||||
+ createFixtureName: (req: CyHttpMessages.IncomingHttpRequest) => {
|
||||
+ // Get the proper fixture file nema for every url
|
||||
+ if (req.url.endsWith('v1/metadata')) {
|
||||
+ return `v1-metadata-${req.body.type}`;
|
||||
+ }
|
||||
+ if (req.url.endsWith('v1alpha1/config')) {
|
||||
+ return `v1alpha1-config`;
|
||||
+ }
|
||||
+ if (req.url.endsWith('v2/query')) {
|
||||
+ return `v2-query-${req.body.type}`;
|
||||
+ }
|
||||
+ if (req.url.endsWith('v1/version')) {
|
||||
+ return `v1-version`;
|
||||
+ }
|
||||
+ throw new Error(`Unknown url ${req.url}`);
|
||||
+ },
|
||||
+ },
|
||||
+ 'http://localhost:8080/**'
|
||||
+ );
|
||||
|
||||
cy.visit('/actions/manage/actions');
|
||||
```
|
||||
|
||||
## Step 4: halt cy.startContractIntercept at the end of the test
|
||||
|
||||
```diff
|
||||
it('When the users create, edit, and delete a Query Action, everything should work', function () {
|
||||
// Code of the test
|
||||
|
||||
+ cy.haltContractIntercept({ thisTest: this.test });
|
||||
})
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
`cy.startContractIntercept` creates a new directory along the test file and stores all the fixtures inside it, something like
|
||||
|
||||
```
|
||||
console/cypress/e2e/actions/query/fixtures/1-v1-metadata-get_inconsistent_metadata.json
|
||||
console/cypress/e2e/actions/query/fixtures/2-v1-metadata-export_metadata.json
|
||||
console/cypress/e2e/actions/query/fixtures/3-v1-metadata-get_catalog_state.json
|
||||
console/cypress/e2e/actions/query/fixtures/4-v1alpha1-config.json
|
||||
console/cypress/e2e/actions/query/fixtures/5-v1-metadata-export_metadata.json
|
||||
console/cypress/e2e/actions/query/fixtures/6-v1alpha1-config.json
|
||||
console/cypress/e2e/actions/query/fixtures/7-v1-version.json
|
||||
console/cypress/e2e/actions/query/fixtures/8-v1-metadata-get_inconsistent_metadata.json
|
||||
console/cypress/e2e/actions/query/fixtures/8-v1-metadata-test_webhook_transform.json
|
||||
console/cypress/e2e/actions/query/fixtures/9-v1-metadata-get_inconsistent_metadata.json
|
||||
console/cypress/e2e/actions/query/fixtures/9-v1-metadata-test_webhook_transform.json
|
||||
console/cypress/e2e/actions/query/fixtures/10-v1-metadata-export_metadata.json
|
||||
```
|
||||
|
||||
and every fixture looks like the following
|
||||
|
||||
```json
|
||||
{
|
||||
"readme": "////////// This fixture has been automatically generated through cy.startContractIntercept //////////",
|
||||
"request": {
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"proxy-connection": "keep-alive",
|
||||
"content-length": "46",
|
||||
"sec-ch-ua": "\".Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"103\", \"Chromium\";v=\"103\"",
|
||||
"x-hasura-admin-secret": "undefined",
|
||||
"content-type": "application/json",
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36",
|
||||
"sec-ch-ua-platform": "\"macOS\"",
|
||||
"accept": "*/*",
|
||||
"sec-fetch-site": "same-site",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-dest": "empty",
|
||||
"referer": "http://localhost:3000/",
|
||||
"accept-encoding": "gzip, deflate, br",
|
||||
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8"
|
||||
},
|
||||
"url": "http://localhost:8080/v1/metadata",
|
||||
"method": "POST",
|
||||
"httpVersion": "1.1",
|
||||
"body": {
|
||||
"type": "get_inconsistent_metadata",
|
||||
"args": {}
|
||||
},
|
||||
"responseTimeout": 30000,
|
||||
"query": {}
|
||||
},
|
||||
"fixtureName": "v1-metadata-get_inconsistent_metadata",
|
||||
"fixtureFileName": "1-v1-metadata-get_inconsistent_metadata.json",
|
||||
"response": {
|
||||
"headers": {
|
||||
"transfer-encoding": "chunked",
|
||||
"date": "Fri, 05 Aug 2022 10:19:32 GMT",
|
||||
"server": "Warp/3.3.19",
|
||||
"x-request-id": "d5f59c25-6275-4298-a3fa-94cc619c0d24",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-encoding": "gzip"
|
||||
},
|
||||
"url": "http://localhost:8080/v1/metadata",
|
||||
"method": null,
|
||||
"httpVersion": "1.1",
|
||||
"statusCode": 200,
|
||||
"statusMessage": "OK",
|
||||
"body": {
|
||||
"is_consistent": true,
|
||||
"inconsistent_objects": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
Loading…
Reference in New Issue
Block a user