mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2025-02-17 00:21:20 -05:00
test: expand health check to multiple services
This commit is contained in:
parent
913428a8b7
commit
d42b56a52f
3 changed files with 217 additions and 52 deletions
|
@ -7,7 +7,7 @@
|
|||
"test": "npm run test:lint && npm run build && npm run test:unit",
|
||||
"test:lint": "eslint . --ext .js,.jsx,.json",
|
||||
"test:lint:ci": "eslint . --ext .js,.jsx,.json --format junit -o ./test/results/lint-results.xml",
|
||||
"test:health": "jest ./test/health/*.test.js",
|
||||
"test:health": "node ./test/health/server-health.mjs",
|
||||
"test:integration": "jest ./test/integration/*.test.js --reporters=default --maxWorkers=5",
|
||||
"test:unit": "npm run test:unit:jest && npm run test:unit:tap",
|
||||
"test:unit:jest": "npm run test:unit:jest:unit && npm run test:unit:jest:localization",
|
||||
|
|
216
test/health/server-health.mjs
Normal file
216
test/health/server-health.mjs
Normal file
|
@ -0,0 +1,216 @@
|
|||
/* eslint-disable no-console */
|
||||
|
||||
// this basic server health check is meant to be run before integration tests
|
||||
// it should be run with the same environment variables as the integration tests
|
||||
// and operate in the same way as the integration tests
|
||||
|
||||
import {fetch} from 'cross-fetch';
|
||||
|
||||
// Every valid PNG starts with this header
|
||||
// Note that the very first byte is 0x89, but JS transforms that to U+FFFD
|
||||
const PNG_HEADER = '\uFFFDPNG\r\n\u001A\n';
|
||||
|
||||
const CAT_A = 'f88bf1935daea28f8ca098462a31dbb0.svg';
|
||||
|
||||
const DELAY_INCREMENT = 5000;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
const rootUrl = process.env.ROOT_URL || (() => {
|
||||
const ROOT_URL_DEFAULT = 'https://scratch.mit.edu';
|
||||
console.warn(`ROOT_URL not set, defaulting to ${ROOT_URL_DEFAULT}`);
|
||||
return ROOT_URL_DEFAULT;
|
||||
})();
|
||||
|
||||
const fallbackUrl = process.env.FALLBACK || (() => {
|
||||
const FALLBACK_DEFAULT = rootUrl;
|
||||
console.warn(`FALLBACK not set, defaulting to ${FALLBACK_DEFAULT}`);
|
||||
return FALLBACK_DEFAULT;
|
||||
})();
|
||||
|
||||
const staticUrl = process.env.STATIC_HOST || (() => {
|
||||
const STATIC_HOST_DEFAULT = rootUrl;
|
||||
console.warn(`STATIC_HOST not set, defaulting to ${STATIC_HOST_DEFAULT}`);
|
||||
return STATIC_HOST_DEFAULT;
|
||||
})();
|
||||
|
||||
const backpackUrl = process.env.BACKPACK_HOST || (() => {
|
||||
const BACKPACK_HOST_DEFAULT = 'https://backpack.scratch.mit.edu';
|
||||
console.warn(`BACKPACK_HOST not set, defaulting to ${BACKPACK_HOST_DEFAULT}`);
|
||||
return BACKPACK_HOST_DEFAULT;
|
||||
})();
|
||||
|
||||
const assetUrl = process.env.ASSET_HOST || (() => {
|
||||
const ASSET_HOST_DEFAULT = 'https://assets.scratch.mit.edu';
|
||||
console.warn(`ASSET_HOST not set, defaulting to ${ASSET_HOST_DEFAULT}`);
|
||||
return ASSET_HOST_DEFAULT;
|
||||
})();
|
||||
|
||||
// TODO: uncomment this when we figure out how to test PROJECT_HOST (see below)
|
||||
// const projectUrl = process.env.PROJECT_HOST || (() => {
|
||||
// const PROJECT_HOST_DEFAULT = 'https://projects.scratch.mit.edu';
|
||||
// console.warn(`PROJECT_HOST not set, defaulting to ${PROJECT_HOST_DEFAULT}`);
|
||||
// return PROJECT_HOST_DEFAULT;
|
||||
// })();
|
||||
|
||||
const apiUrl = process.env.API_HOST || (() => {
|
||||
const API_HOST_DEFAULT = 'https://api.scratch.mit.edu';
|
||||
console.warn(`API_HOST not set, defaulting to ${API_HOST_DEFAULT}`);
|
||||
return API_HOST_DEFAULT;
|
||||
})();
|
||||
|
||||
const cloudDataUrl = process.env.CLOUDDATA_HOST || (() => {
|
||||
const CLOUDDATA_HOST_DEFAULT = 'https://clouddata.scratch.mit.edu';
|
||||
console.warn(`CLOUDDATA_HOST not set, defaulting to ${CLOUDDATA_HOST_DEFAULT}`);
|
||||
return CLOUDDATA_HOST_DEFAULT;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Sleep for the given number of milliseconds.
|
||||
* @param {number} ms Number of milliseconds to sleep for.
|
||||
* @returns {Promise} A promise that resolves after the given number of milliseconds.
|
||||
*/
|
||||
const sleepMs = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Try a function several times, with a delay between each attempt.
|
||||
* @param {Function} fn Try this function several times. If it throws, try again until the limit is reached.
|
||||
* @returns {Promise} A promise that resolves to the return value of the function.
|
||||
* @throws {Error} If the function throws an error after the maximum number of attempts.
|
||||
*/
|
||||
const trySeveralTimes = async fn => {
|
||||
let attempts = 0;
|
||||
let lastError;
|
||||
while (attempts < MAX_ATTEMPTS) {
|
||||
if (attempts > 0) {
|
||||
const delay = attempts * DELAY_INCREMENT;
|
||||
console.warn(`Retrying after error: ${lastError}`);
|
||||
console.warn(`Waiting ${delay}ms before retrying...`);
|
||||
await sleepMs(delay);
|
||||
}
|
||||
try {
|
||||
const result = await fn();
|
||||
// console.log('trySeveralTimes result', result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a resource is reachable, retrying several times if it is not.
|
||||
* If the resource is not reachable after several retries, throw an error.
|
||||
* @param {string|URL|Request} resource The resource to fetch.
|
||||
* @param {string} name A name for the resource, for logging purposes.
|
||||
*/
|
||||
const checkReachable = async (resource, name) => {
|
||||
await trySeveralTimes(async () => {
|
||||
const result = await fetch(resource);
|
||||
if (!result.ok) {
|
||||
throw new Error(`${name} failed with status ${result.status}`);
|
||||
}
|
||||
});
|
||||
console.log(`${name} is reachable`);
|
||||
};
|
||||
|
||||
/**
|
||||
* @callback TextCheck
|
||||
* @param {string} body The body text of the response.
|
||||
* @returns {boolean|Promise<boolean>} Whether the body is healthy.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attempt to fetch a resource and verify correctness of the response body.
|
||||
* If the fetch response is OK, check the response body with the given function.
|
||||
* If fetching fails or the body check fails, retry several times.
|
||||
* If enough retries fail, throw the most recent error.
|
||||
* @param {string|URL|Request} resource The resource to fetch.
|
||||
* @param {string} name A name for the resource, for logging purposes.
|
||||
* @param {TextCheck} check A function to check the body text of the response.
|
||||
*/
|
||||
const checkBody = async (resource, name, check) => {
|
||||
await trySeveralTimes(async () => {
|
||||
const result = await fetch(resource);
|
||||
if (!result.ok) {
|
||||
throw new Error(`${name} failed with status ${result.status}`);
|
||||
}
|
||||
const body = await result.text();
|
||||
if (!await check(body)) {
|
||||
throw new Error(`${name} failed with unexpected body`);
|
||||
}
|
||||
});
|
||||
console.log(`${name} is healthy`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the health check: check reachability or health for each service used by the integration tests. One or two
|
||||
* services are tested on their own initially, then the rest of the services are tested together asynchronously.
|
||||
* @returns {Promise} A promise that resolves when the health check is complete.
|
||||
*/
|
||||
const runHealthCheck = async () => {
|
||||
// STAGING_HOST is only set in the staging environment, so it's not worth warning about in other environments
|
||||
if (process.env.STAGING_HOST) {
|
||||
await checkReachable(process.env.STAGING_HOST, 'STAGING_HOST');
|
||||
}
|
||||
await checkReachable(rootUrl, 'ROOT_URL'); // scratch-www
|
||||
await Promise.all([
|
||||
// backpack
|
||||
checkBody(
|
||||
backpackUrl,
|
||||
'BACKPACK_HOST',
|
||||
body => JSON.parse(body).ok === String.fromCodePoint(0x1F47B)
|
||||
),
|
||||
|
||||
// assets
|
||||
// TODO: check `${assetUrl}/health` when it's available
|
||||
checkBody(
|
||||
new URL(CAT_A, assetUrl),
|
||||
'ASSET_HOST',
|
||||
body => {
|
||||
const svgOpen = body.indexOf('<svg');
|
||||
const svgClose = body.indexOf('</svg>');
|
||||
return svgOpen >= 0 &&
|
||||
svgOpen < 128 &&
|
||||
svgClose > svgOpen;
|
||||
}
|
||||
),
|
||||
|
||||
// projects
|
||||
// TODO: check `${projectUrl}/health` when it's available
|
||||
// check???(projectUrl, 'PROJECT_HOST'),
|
||||
|
||||
// static
|
||||
checkBody(
|
||||
new URL('/get_image/user/default_32x32.png', staticUrl),
|
||||
'STATIC_HOST',
|
||||
body => body.indexOf(PNG_HEADER) === 0
|
||||
),
|
||||
|
||||
// API
|
||||
checkBody(
|
||||
new URL('/health', apiUrl),
|
||||
'API_HOST',
|
||||
body => JSON.parse(body).version.length > 0
|
||||
),
|
||||
|
||||
// API fallback
|
||||
checkReachable(fallbackUrl, 'FALLBACK'),
|
||||
|
||||
// cloud data / cloud variables
|
||||
checkBody(
|
||||
new URL('/health', cloudDataUrl),
|
||||
'CLOUDDATA_HOST',
|
||||
body => JSON.parse(body).uptime > 0
|
||||
)
|
||||
]);
|
||||
};
|
||||
|
||||
runHealthCheck().then(
|
||||
() => console.log('Health check complete'),
|
||||
e => {
|
||||
console.error('Health check failed', e);
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
|
@ -1,51 +0,0 @@
|
|||
/* eslint-disable no-console */
|
||||
|
||||
// this basic server health check is meant to be run before integration tests
|
||||
// it should be run with the same environment variables as the integration tests
|
||||
// and operate in the same way as the integration tests
|
||||
|
||||
const SeleniumHelper = require('../integration/selenium-helpers.js');
|
||||
|
||||
const rootUrl = process.env.ROOT_URL || (() => {
|
||||
const ROOT_URL_DEFAULT = 'https://scratch.ly';
|
||||
console.warn(`ROOT_URL not set, defaulting to ${ROOT_URL_DEFAULT}`);
|
||||
return ROOT_URL_DEFAULT;
|
||||
})();
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe('www server health check', () => {
|
||||
/** @type {import('selenium-webdriver').ThenableWebDriver} */
|
||||
let driver;
|
||||
|
||||
/** @type {SeleniumHelper} */
|
||||
let seleniumHelper;
|
||||
|
||||
beforeAll(() => {
|
||||
seleniumHelper = new SeleniumHelper();
|
||||
driver = seleniumHelper.buildDriver('www server health check');
|
||||
});
|
||||
|
||||
afterAll(() => driver.quit());
|
||||
|
||||
test('server is healthy', async () => {
|
||||
const healthUrl = new URL('health/', rootUrl);
|
||||
await driver.get(healthUrl.toString());
|
||||
|
||||
// Note: driver.getPageSource() will return the pretty HTML form of the JSON
|
||||
const pageText = await driver.executeScript('return document.body.innerText');
|
||||
|
||||
let healthObject;
|
||||
let serverReturnedValidJson = false;
|
||||
|
||||
try {
|
||||
healthObject = JSON.parse(pageText);
|
||||
serverReturnedValidJson = true;
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
expect(serverReturnedValidJson).toBe(true);
|
||||
expect(healthObject).toHaveProperty('healthy', true);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue