Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a1555f1
fix(cypress): adjust task timeout and enhance logging for teardown pr…
martinjagodic Mar 5, 2026
3e8f9bc
Merge branch 'main' into cypress-teardown
yanthomasdev Mar 5, 2026
5f5cbb1
Apply suggestion from @yanthomasdev
martinjagodic Mar 6, 2026
75beb08
fix(cypress): enhance logging and improve API request timeouts in git…
martinjagodic Mar 6, 2026
0b1476a
fix(cypress): enhance logging for media upload and test setup processes
martinjagodic Mar 6, 2026
ff008ee
fix(cypress): adjust timeout settings and refine clock handling in tests
martinjagodic Mar 6, 2026
421a590
fix(cypress): remove unnecessary logging from uploadMediaFile and bef…
martinjagodic Mar 6, 2026
723b1ef
fix(cypress): remove unnecessary logging, fix format in media library…
martinjagodic Mar 6, 2026
b3052f3
fix(cypress): refactor API request handling and enhance logging in te…
martinjagodic Mar 6, 2026
d976e62
fix(cypress): streamline API calls by combining fetch functions
martinjagodic Mar 6, 2026
3dc3577
fix(cypress): increase retry delay in waitForDeploys function and imp…
martinjagodic Mar 6, 2026
a748cc1
fix(cypress): optimize element selection by using aliases
martinjagodic Mar 6, 2026
cb3ab69
fix(cypress): enhance logging for better traceability during tests an…
martinjagodic Mar 6, 2026
c804cbb
fix(cypress): enhance logging in test lifecycle hooks and improve err…
martinjagodic Mar 9, 2026
17b2a48
fix(cypress): improve logging during media file upload and enhance er…
martinjagodic Mar 9, 2026
a005532
fix(cypress): streamline logging in media library and login functions…
martinjagodic Mar 9, 2026
d679f64
fix(cypress): reduce logging in beforeEach and remove unnecessary log…
martinjagodic Mar 9, 2026
08cde46
fix(gitGateway): make fetchWithTimeout handle full URLs, remove wrapp…
martinjagodic Mar 9, 2026
b220729
fix(cypress): enhance logging in media library tests and utility func…
martinjagodic Mar 9, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ jobs:
env:
CI_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
IS_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true || github.repository_owner != 'decaporg' }}
# Only used for fork PRs (can't access CYPRESS_RECORD_KEY)
MACHINE_INDEX: ${{ matrix.machine }}
MACHINE_COUNT: 4
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
Expand Down
4 changes: 1 addition & 3 deletions cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import { defineConfig } from 'cypress';
export default defineConfig({
projectId: '1c35bs',
retries: {
runMode: 2, // Reduced from 4 - Cypress Cloud helps identify flaky tests
runMode: 2,
openMode: 0,
},
e2e: {
video: false,
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('./cypress/plugins/index.js')(on, config);
Expand Down
31 changes: 19 additions & 12 deletions cypress/e2e/common/spec_utils.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
export const before = (taskResult, options, backend) => {
Cypress.config('taskTimeout', 7 * 60 * 1000);
export function before(taskResult, options, backend) {
Cypress.config('taskTimeout', 5 * 60 * 1000); // 5 minutes
cy.task('setupBackend', { backend, options }).then(data => {
taskResult.data = data;
Cypress.config('defaultCommandTimeout', data.mockResponses ? 5 * 1000 : 1 * 60 * 1000);
});
};
}

export const after = (taskResult, backend) => {
export function after(taskResult, backend) {
cy.task('teardownBackend', {
backend,
...taskResult.data,
});
};
}

export const beforeEach = (taskResult, backend) => {
export function beforeEach(taskResult, backend) {
const spec = Cypress.mocha.getRunner().suite.ctx.currentTest.parent.title;
const testName = Cypress.mocha.getRunner().suite.ctx.currentTest.title;

cy.task('setupBackendTest', {
backend,
...taskResult.data,
Expand All @@ -29,10 +30,15 @@ export const beforeEach = (taskResult, backend) => {
cy.stubFetch({ fixture });
}

return cy.clock(0, ['Date']);
};
// cy.clock(0, ['Date']) was hanging git-gateway tests after page load
// Hypothesis: freezing time to 0 breaks app initialization during cy.visit()
// Temporary fix: skip cy.clock for git-gateway, use default clock for others
if (backend !== 'git-gateway') {
return cy.clock(0, ['Date']);
}
}

export const afterEach = (taskResult, backend) => {
export function afterEach(taskResult, backend) {
const spec = Cypress.mocha.getRunner().suite.ctx.currentTest.parent.title;
const testName = Cypress.mocha.getRunner().suite.ctx.currentTest.title;

Expand All @@ -51,15 +57,16 @@ export const afterEach = (taskResult, backend) => {
},
},
} = Cypress.mocha.getRunner();

if (state === 'failed' && retries === currentRetry) {
Cypress.runner.stop();
}
}
};
}

export const seedRepo = (taskResult, backend) => {
export function seedRepo(taskResult, backend) {
cy.task('seedRepo', {
backend,
...taskResult.data,
});
};
}
108 changes: 60 additions & 48 deletions cypress/plugins/gitGateway.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const fetch = require('node-fetch');

const {
transformRecordedData: transformGitHub,
setupGitHub,
Expand Down Expand Up @@ -32,52 +33,48 @@ function getEnvs() {

const apiRoot = 'https://api.netlify.com/api/v1/';

async function get(netlifyApiToken, path) {
const response = await fetch(`${apiRoot}${path}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
}).then(res => res.json());

return response;
}

async function post(netlifyApiToken, path, payload) {
const response = await fetch(`${apiRoot}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
...(payload ? { body: JSON.stringify(payload) } : {}),
}).then(res => res.json());

return response;
}

async function del(netlifyApiToken, path) {
const response = await fetch(`${apiRoot}${path}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
}).then(res => res.text());

return response;
async function fetchWithTimeout(netlifyApiToken, path, method = 'GET', payload = null, parseAs = 'json') {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout

try {
const options = {
signal: controller.signal,
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
};

if (payload) {
options.body = JSON.stringify(payload);
}

const response = await fetch(`${apiRoot}${path}`, options);
clearTimeout(timeout);

return parseAs === 'json' ? response.json() : response.text();
} catch (error) {
clearTimeout(timeout);
if (error.name === 'AbortError') {
console.error(`Netlify API ${method} timeout after 10s: ${path}`);
throw new Error(`Netlify API ${method} request timeout: ${path}`);
}
throw error;
}
}

async function createSite(netlifyApiToken, payload) {
return post(netlifyApiToken, 'sites', payload);
return fetchWithTimeout(netlifyApiToken, 'sites', 'POST', payload);
}

async function enableIdentity(netlifyApiToken, siteId) {
return post(netlifyApiToken, `sites/${siteId}/identity`, {});
return fetchWithTimeout(netlifyApiToken, `sites/${siteId}/identity`, 'POST', {});
}

async function enableGitGateway(netlifyApiToken, siteId, provider, token, repo) {
return post(netlifyApiToken, `sites/${siteId}/services/git/instances`, {
return fetchWithTimeout(netlifyApiToken, `sites/${siteId}/services/git/instances`, 'POST', {
[provider]: {
repo,
access_token: token,
Expand All @@ -86,20 +83,33 @@ async function enableGitGateway(netlifyApiToken, siteId, provider, token, repo)
}

async function enableLargeMedia(netlifyApiToken, siteId) {
return post(netlifyApiToken, `sites/${siteId}/services/large-media/instances`, {});
return fetchWithTimeout(netlifyApiToken, `sites/${siteId}/services/large-media/instances`, 'POST', {});
}

async function waitForDeploys(netlifyApiToken, siteId) {
for (let i = 0; i < 10; i++) {
const deploys = await get(netlifyApiToken, `sites/${siteId}/deploys`);
if (deploys.some(deploy => deploy.state === 'ready')) {
console.log('Deploy finished for site:', siteId);
return;
const maxRetries = 5;
const retryDelayMs = 15 * 1000; // 15 seconds between retries

for (let i = 0; i < maxRetries; i++) {
try {
const deploys = await fetchWithTimeout(netlifyApiToken, `sites/${siteId}/deploys`);

if (deploys && deploys.some(deploy => deploy.state === 'ready')) {
return;
}

if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
} catch (error) {
console.error(`Error checking deploy status: ${error.message}`);
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
}
console.log('Waiting on deploy of site:', siteId);
await new Promise(resolve => setTimeout(resolve, 30 * 1000));
}
console.log('Timed out waiting on deploy of site:', siteId);

throw new Error(`Timed out waiting for deploy of site ${siteId} after ${maxRetries * retryDelayMs / 1000}s`);
}

async function createUser(netlifyApiToken, siteUrl, email, password) {
Expand Down Expand Up @@ -150,7 +160,7 @@ const methods = {
teardownTest: teardownGitLabTest,
transformData: transformGitLab,
createSite: async (netlifyApiToken, result) => {
const { id, public_key } = await post(netlifyApiToken, 'deploy_keys');
const { id, public_key } = await fetchWithTimeout(netlifyApiToken, 'deploy_keys', 'POST');
const { gitlabToken } = getEnvs();
const project = `${result.owner}/${result.repo}`;
await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/deploy_keys`, {
Expand Down Expand Up @@ -259,6 +269,7 @@ async function setupGitGateway(options) {
provider,
};
} else {
console.log('Running tests in "playback" mode - local data will be used');
return {
...result,
user: {
Expand All @@ -269,6 +280,7 @@ async function setupGitGateway(options) {
password,
},
provider,
mockResponses: true,
};
}
}
Expand All @@ -278,7 +290,7 @@ async function teardownGitGateway(taskData) {
const { netlifyApiToken } = getEnvs();
const { site_id } = taskData;
console.log('Deleting Netlify site:', site_id);
await del(netlifyApiToken, `sites/${site_id}`);
await fetchWithTimeout(netlifyApiToken, `sites/${site_id}`, 'DELETE', null, 'text');

const result = await methods[taskData.provider].teardown(taskData);
return result;
Expand Down
4 changes: 3 additions & 1 deletion cypress/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ async function runCypress() {
const machineCount = Number(process.env.MACHINE_COUNT || 0);
const isFork = process.env.IS_FORK === 'true';

console.log(`IS_FORK: ${isFork}, MACHINE_INDEX: ${machineIndex}, MACHINE_COUNT: ${machineCount}`);

if (isFork && machineIndex && machineCount) {
const specsPerMachine = Math.floor(specs.length / machineCount);
const start = (machineIndex - 1) * specsPerMachine;
Expand Down Expand Up @@ -58,7 +60,7 @@ async function runCypress() {
await execa('cypress', args, {
stdio: 'inherit',
preferLocal: true,
timeout: 60 * 60 * 1000, // 1 hour
timeout: 15 * 60 * 1000, // 15 minutes
});
}

Expand Down
6 changes: 5 additions & 1 deletion cypress/support/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ addMatchImageSnapshotCommand({
capture: 'viewport',
});

Cypress.on('uncaught:exception', () => false);
Cypress.on('uncaught:exception', (err) => {
console.error('[UNCAUGHT EXCEPTION]', err.message);
console.error('[UNCAUGHT EXCEPTION] Stack:', err.stack);
return false; // Prevent Cypress from failing the test
});

import './commands';

Expand Down
33 changes: 15 additions & 18 deletions cypress/utils/steps.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ function login(user) {
},
});
if (user.netlifySiteURL && user.email && user.password) {
cy.get('input[name="email"]')
.clear()
.type(user.email);
cy.get('input[name="password"]')
.clear()
.type(user.password);
cy.get('input[name="email"]', { timeout: 10000 }).clear();
cy.get('input[name="email"]').type(user.email);
cy.get('input[name="password"]').clear();
cy.get('input[name="password"]').type(user.password);
cy.contains('button', 'Login').click();
}
} else {
Expand Down Expand Up @@ -56,6 +54,7 @@ function assertColorOn(cssProperty, color, opts) {
expect($el).to.have.css(cssProperty, color);
});
} else if (opts.type && opts.type === 'field') {
// eslint-disable-next-line func-style
const assertion = $el => expect($el).to.have.css(cssProperty, color);
if (opts.isMarkdown) {
(opts.scope ? opts.scope : cy)
Expand Down Expand Up @@ -270,11 +269,10 @@ function populateEntry(entry, onDone = flushClockAndSave) {
for (const key of keys) {
const value = entry[key];
if (key === 'body') {
cy.getMarkdownEditor()
.first()
.click()
.clear({ force: true })
.type(value, { force: true });
cy.getMarkdownEditor().first().as('bodyEditor');
cy.get('@bodyEditor').click();
cy.get('@bodyEditor').clear({ force: true });
cy.get('@bodyEditor').type(value, { force: true });
} else {
cy.get(`[id^="${key}-field"]`)
.first()
Expand Down Expand Up @@ -467,17 +465,16 @@ function validateNestedObjectFields({ limit, author }) {
cy.focused().type(author);
cy.contains('button', 'Save').click();
assertNotification(notifications.error.missingField);
cy.get('input[type=number]').type(limit + 1);
cy.get('input[type=number]').as('limitInput');
cy.get('@limitInput').type(limit + 1);
cy.contains('button', 'Save').click();
assertFieldValidationError(notifications.validation.range);
cy.get('input[type=number]')
.clear()
.type(-1);
cy.get('@limitInput').clear();
cy.get('@limitInput').type(-1);
cy.contains('button', 'Save').click();
assertFieldValidationError(notifications.validation.range);
cy.get('input[type=number]')
.clear()
.type(limit);
cy.get('@limitInput').clear();
cy.get('@limitInput').type(limit);
cy.contains('button', 'Save').click();
assertNotification(notifications.saved);
}
Expand Down