diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 5123559d8742..6dea8621ca79 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -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 }} diff --git a/cypress.config.ts b/cypress.config.ts index 1258f312d19b..2b681cb7d3fc 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -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); diff --git a/cypress/e2e/common/media_library.js b/cypress/e2e/common/media_library.js index 891ff1ead402..060c809231a0 100644 --- a/cypress/e2e/common/media_library.js +++ b/cypress/e2e/common/media_library.js @@ -102,14 +102,22 @@ function assertGridEntryImage(entry) { export default function({ entries, getUser }) { beforeEach(() => { - login(getUser && getUser()); + console.log('[media_library.beforeEach] START'); + const user = getUser && getUser(); + console.log('[media_library.beforeEach] user=', user ? JSON.stringify(user) : 'none'); + login(user); + console.log('[media_library.beforeEach] login() returned'); }); it('can upload image from global media library', () => { + console.log('[TEST] can upload image from global media library - START'); goToMediaLibrary(); + console.log('[TEST] goToMediaLibrary() completed'); uploadMediaFile(); + console.log('[TEST] uploadMediaFile() completed'); matchImageSnapshot(); closeMediaLibrary(); + console.log('[TEST] can upload image from global media library - END'); }); it('can delete image from global media library', () => { diff --git a/cypress/e2e/common/spec_utils.js b/cypress/e2e/common/spec_utils.js index dc81c5f3e945..8bcec631e198 100644 --- a/cypress/e2e/common/spec_utils.js +++ b/cypress/e2e/common/spec_utils.js @@ -1,38 +1,63 @@ -export const before = (taskResult, options, backend) => { - Cypress.config('taskTimeout', 7 * 60 * 1000); +export function before(taskResult, options, backend) { + console.log(`[spec_utils.before] START backend=${backend}`); + Cypress.config('taskTimeout', 5 * 60 * 1000); // 5 minutes cy.task('setupBackend', { backend, options }).then(data => { + console.log('[spec_utils.before] setupBackend completed, data=', data); taskResult.data = data; Cypress.config('defaultCommandTimeout', data.mockResponses ? 5 * 1000 : 1 * 60 * 1000); + console.log(`[spec_utils.before] COMPLETE mockResponses=${data.mockResponses} timeout=${data.mockResponses ? 5000 : 60000}ms`); }); -}; +} -export const after = (taskResult, backend) => { +export function after(taskResult, backend) { + console.log(`[spec_utils.after] START backend=${backend}`); cy.task('teardownBackend', { backend, ...taskResult.data, + }).then(() => { + console.log('[spec_utils.after] COMPLETE'); }); -}; +} -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; + + console.log(`[spec_utils.beforeEach] START backend=${backend} spec="${spec}" test="${testName}"`); + console.log(`[spec_utils.beforeEach] mockResponses=${taskResult.data.mockResponses}`); + console.log(`[spec_utils.beforeEach] user=`, JSON.stringify(taskResult.data.user || {})); + cy.task('setupBackendTest', { backend, ...taskResult.data, spec, testName, + }).then(() => { + console.log('[spec_utils.beforeEach] setupBackendTest completed'); }); if (taskResult.data.mockResponses) { const fixture = `${spec}__${testName}.json`; - console.log('loading fixture:', fixture); - cy.stubFetch({ fixture }); + console.log(`[spec_utils.beforeEach] Loading fixture: ${fixture}`); + cy.stubFetch({ fixture }).then(() => { + console.log('[spec_utils.beforeEach] stubFetch completed'); + }); + } else { + console.log('[spec_utils.beforeEach] WARNING: mockResponses is false/undefined - no fixture loaded'); } - 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') { + console.log('[spec_utils.beforeEach] Setting clock to epoch 0'); + return cy.clock(0, ['Date']); + } -export const afterEach = (taskResult, backend) => { + console.log('[spec_utils.beforeEach] COMPLETE - skipped clock for git-gateway'); +} + +export function afterEach(taskResult, backend) { const spec = Cypress.mocha.getRunner().suite.ctx.currentTest.parent.title; const testName = Cypress.mocha.getRunner().suite.ctx.currentTest.title; @@ -51,15 +76,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, }); -}; +} diff --git a/cypress/e2e/media_library_spec_git-gateway_gitlab_backend_large_media.js b/cypress/e2e/media_library_spec_git-gateway_gitlab_backend_large_media.js index 0a62c54f3415..65cc4f5bc855 100644 --- a/cypress/e2e/media_library_spec_git-gateway_gitlab_backend_large_media.js +++ b/cypress/e2e/media_library_spec_git-gateway_gitlab_backend_large_media.js @@ -6,23 +6,33 @@ const backend = 'git-gateway'; const provider = 'gitlab'; describe('Git Gateway (GitLab) Backend Media Library - Large Media', () => { - let taskResult = { data: {} }; + const taskResult = { data: {} }; before(() => { + console.log('[SPEC before] START'); specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend); + console.log('[SPEC before] COMPLETE, taskResult.data=', taskResult.data); }); after(() => { + console.log('[SPEC after] START'); specUtils.after(taskResult, backend); + console.log('[SPEC after] COMPLETE'); }); beforeEach(() => { + console.log('[SPEC beforeEach] START, taskResult.data=', taskResult.data); specUtils.beforeEach(taskResult, backend); + console.log('[SPEC beforeEach] COMPLETE'); }); afterEach(() => { + console.log('[SPEC afterEach] START'); specUtils.afterEach(taskResult, backend); + console.log('[SPEC afterEach] COMPLETE'); }); + console.log('[SPEC] About to call fixture()'); fixture({ entries: [entry1], getUser: () => taskResult.data.user }); + console.log('[SPEC] fixture() returned'); }); diff --git a/cypress/plugins/gitGateway.js b/cypress/plugins/gitGateway.js index e06ff1f7a63b..b7280dd95ea3 100644 --- a/cypress/plugins/gitGateway.js +++ b/cypress/plugins/gitGateway.js @@ -1,4 +1,5 @@ const fetch = require('node-fetch'); + const { transformRecordedData: transformGitHub, setupGitHub, @@ -32,91 +33,64 @@ 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 createSite(netlifyApiToken, payload) { - return post(netlifyApiToken, 'sites', payload); -} - -async function enableIdentity(netlifyApiToken, siteId) { - return post(netlifyApiToken, `sites/${siteId}/identity`, {}); -} - -async function enableGitGateway(netlifyApiToken, siteId, provider, token, repo) { - return post(netlifyApiToken, `sites/${siteId}/services/git/instances`, { - [provider]: { - repo, - access_token: token, - }, - }); -} - -async function enableLargeMedia(netlifyApiToken, siteId) { - return post(netlifyApiToken, `sites/${siteId}/services/large-media/instances`, {}); -} - -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; +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); } - console.log('Waiting on deploy of site:', siteId); - await new Promise(resolve => setTimeout(resolve, 30 * 1000)); + + // Handle both full URLs and API paths + const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${apiRoot}${path}`; + const response = await fetch(url, 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; } - console.log('Timed out waiting on deploy of site:', siteId); } -async function createUser(netlifyApiToken, siteUrl, email, password) { - const response = await fetch(`${siteUrl}/.netlify/functions/create-user`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${netlifyApiToken}`, - }, - body: JSON.stringify({ email, password }), - }); - - if (response.ok) { - console.log('User created successfully'); - } else { - throw new Error('Failed to create user'); +async function waitForDeploys(netlifyApiToken, siteId) { + 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)); + } + } } + + throw new Error(`Timed out waiting for deploy of site ${siteId} after ${maxRetries * retryDelayMs / 1000}s`); } const netlifySiteURL = 'https://fake-site-url.netlify.com/'; @@ -133,7 +107,8 @@ const methods = { transformData: transformGitHub, createSite: (netlifyApiToken, result) => { const { installationId } = getEnvs(); - return createSite(netlifyApiToken, { + // Create a new Netlify site connected to GitHub repository + return fetchWithTimeout(netlifyApiToken, 'sites', 'POST', { repo: { provider: 'github', installation_id: installationId, @@ -150,7 +125,8 @@ const methods = { teardownTest: teardownGitLabTest, transformData: transformGitLab, createSite: async (netlifyApiToken, result) => { - const { id, public_key } = await post(netlifyApiToken, 'deploy_keys'); + // Generate a deploy key for GitLab + 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`, { @@ -162,7 +138,8 @@ const methods = { body: JSON.stringify({ title: 'Netlify Deploy Key', key: public_key, can_push: false }), }).then(res => res.json()); - const site = await createSite(netlifyApiToken, { + // Create a new Netlify site connected to GitLab repository + const site = await fetchWithTimeout(netlifyApiToken, 'sites', 'POST', { account_slug: result.owner, repo: { provider: 'gitlab', @@ -208,20 +185,22 @@ async function setupGitGateway(options) { } console.log('Enabling identity for site:', site_id); - await enableIdentity(netlifyApiToken, site_id); + // Enable Netlify Identity + await fetchWithTimeout(netlifyApiToken, `sites/${site_id}/identity`, 'POST', {}); console.log('Enabling git gateway for site:', site_id); const token = methods[provider].token(); - await enableGitGateway( - netlifyApiToken, - site_id, - provider, - token, - `${result.owner}/${result.repo}`, - ); + // Enable Git Gateway + await fetchWithTimeout(netlifyApiToken, `sites/${site_id}/services/git/instances`, 'POST', { + [provider]: { + repo: `${result.owner}/${result.repo}`, + access_token: token, + }, + }); console.log('Enabling large media for site:', site_id); - await enableLargeMedia(netlifyApiToken, site_id); + // Enable Large Media + await fetchWithTimeout(netlifyApiToken, `sites/${site_id}/services/large-media/instances`, 'POST', {}); const git = getGitClient(result.tempDir); await git.raw([ @@ -240,7 +219,15 @@ async function setupGitGateway(options) { console.log('Creating user for site:', site_id, 'with email:', email); try { - await createUser(netlifyApiToken, ssl_url, email, password); + // Create a user account via Netlify Identity + await fetchWithTimeout( + netlifyApiToken, + `${ssl_url}/.netlify/functions/create-user`, + 'POST', + { email, password }, + 'text' + ); + console.log('User created successfully'); } catch (e) { console.log(e); } @@ -259,6 +246,7 @@ async function setupGitGateway(options) { provider, }; } else { + console.log('Running tests in "playback" mode - local data will be used'); return { ...result, user: { @@ -269,6 +257,7 @@ async function setupGitGateway(options) { password, }, provider, + mockResponses: true, }; } } @@ -278,7 +267,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; diff --git a/cypress/run.mjs b/cypress/run.mjs index e5103b735af7..3565990cff74 100644 --- a/cypress/run.mjs +++ b/cypress/run.mjs @@ -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; @@ -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 }); } diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index 80fcca6176c2..4b2432a95ca2 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -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'; diff --git a/cypress/utils/steps.js b/cypress/utils/steps.js index e392ed8c8acb..6ed02e4cbc3b 100644 --- a/cypress/utils/steps.js +++ b/cypress/utils/steps.js @@ -9,8 +9,10 @@ const { } = require('./constants'); function login(user) { + console.log('[login] START user=', user ? 'yes' : 'no', 'netlifySiteURL=', user?.netlifySiteURL || 'none'); cy.viewport(1200, 1200); if (user) { + console.log('[login] About to cy.visit("/")'); cy.visit('/', { onBeforeLoad: () => { // https://github.com/cypress-io/cypress/issues/1208 @@ -19,22 +21,28 @@ function login(user) { if (user.netlifySiteURL) { window.localStorage.setItem('netlifySiteURL', user.netlifySiteURL); } + console.log('[login] onBeforeLoad complete'); }, + }).then(() => { + console.log('[login] cy.visit completed'); }); 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); + console.log('[login] Filling login form'); + 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(); + console.log('[login] Login button clicked'); } } else { cy.visit('/'); cy.contains('button', 'Login').click(); } - cy.contains('a', 'New Post'); + console.log('[login] Waiting for New Post link'); + cy.contains('a', 'New Post', { timeout: 60000 }).then(() => { + console.log('[login] New Post link found - COMPLETE'); + }); } function assertNotification(message) { @@ -56,6 +64,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) @@ -270,11 +279,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() @@ -467,17 +475,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); }