Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: SSR E2E Testing Framework #18779

Merged
merged 18 commits into from
May 16, 2024
Merged
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .env-cmdrc
Original file line number Diff line number Diff line change
@@ -5,6 +5,9 @@
"local": {
"CX_BASE_URL": "https://localhost:9002"
},
"local-http": {
"CX_BASE_URL": "http://localhost:9002"
},
"ci": {
"CX_BASE_URL": "https://20.83.184.244:9002"
},
2 changes: 1 addition & 1 deletion ci-scripts/unit-tests.sh
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@
set -e
set -o pipefail

EXCLUDE_APPLICATIONS=storefrontapp
EXCLUDE_APPLICATIONS=storefrontapp,ssr-tests
Zeyber marked this conversation as resolved.
Show resolved Hide resolved
EXCLUDE_JEST=storefrontstyles,schematics,setup

echo "-----"
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@
"build:setup": "nx build setup --configuration production",
"build:ssr": "env-cmd --no-override -e dev,b2c,$SPA_ENV nx run storefrontapp:server:production",
"build:ssr:ci": "env-cmd -e ci,b2c,$SPA_ENV nx run storefrontapp:server:production",
"build:ssr:local-http": "env-cmd -e local-http,b2c,$SPA_ENV nx run storefrontapp:server:production",
"build:storefinder": "npm --prefix feature-libs/storefinder run build:schematics && nx build storefinder --configuration production",
"build:smartedit": "npm --prefix feature-libs/smartedit run build:schematics && nx build smartedit --configuration production",
"build:tracking": "npm --prefix feature-libs/tracking run build:schematics && nx build tracking --configuration production",
@@ -100,6 +101,8 @@
"serve:ssr": "node dist/storefrontapp-server/main.js",
"serve:ssr:ci": "NODE_TLS_REJECT_UNAUTHORIZED=0 SSR_TIMEOUT=0 node dist/storefrontapp-server/main.js",
"serve:ssr:dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 node dist/storefrontapp-server/main.js",
"test:ssr": "env-cmd -e dev nx test ssr-tests",
"test:ssr:ci": "env-cmd -e ci nx test ssr-tests",
"prerender": "nx run storefrontapp:prerender --routes-file projects/storefrontapp/prerender.txt",
"prerender:dev": "env-cmd --no-override -e dev,$SPA_ENV cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 nx run storefrontapp:prerender --routes-file projects/storefrontapp/prerender.txt",
"prerender:cds": "env-cmd --no-override -e cds nx run storefrontapp:prerender --routes-file projects/storefrontapp/prerender.txt",
@@ -195,6 +198,7 @@
"eslint-plugin-prefer-arrow": "^1.2.3",
"fs-extra": "^11.1.1",
"glob": "^7.1.6",
"http-proxy": "^1.18.1",
"http-server": "^14.1.1",
"i18n-lint": "^1.1.0",
"jasmine-core": "~5.1.0",
3 changes: 3 additions & 0 deletions projects/schematics/src/dependencies.json
Original file line number Diff line number Diff line change
@@ -23,6 +23,9 @@
"parse5": "^7.1.2",
"typescript": "^5.2.2"
},
"ssr-tests": {
"http-proxy": "^1.18.1"
},
"storefrontapp-e2e-cypress": {},
"@spartacus/storefront": {
"@angular/common": "^17.0.5",
3 changes: 2 additions & 1 deletion projects/schematics/src/shared/utils/graph-utils.ts
Original file line number Diff line number Diff line change
@@ -136,7 +136,8 @@ export function kahnsAlgorithm(graph: Graph): string[] {
function createLibraryDependencyGraph(): Graph {
const skip = CORE_SPARTACUS_SCOPES.concat(
'storefrontapp-e2e-cypress',
'storefrontapp'
'storefrontapp',
'ssr-tests'
);

const spartacusLibraries = Object.keys(collectedDependencies).filter(
12 changes: 12 additions & 0 deletions projects/ssr-tests/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": ["**/*.d.ts"],
"overrides": [
{
"files": ["*.ts"],
"rules": {
"no-console": "off"
}
}
]
}
1 change: 1 addition & 0 deletions projects/ssr-tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.ssr.log
37 changes: 37 additions & 0 deletions projects/ssr-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Spartacus SSR E2E Test Framework

This project is for testing server-side rendering in Spartacus.

## Running Tests

Before running the test suite, we first need to build our application with `npm install && npm run build:libs && npm run build:ssr:local-http` from the root of the project.

After the build, use the `npm run test:ssr` and `npm run test:ssr:ci` commands from the root of the project to run the tests.

### Retesting after changes

If we change something in the application itself such as library code, we need to rebuild libraries and application again in order to test the changes.

Note: Build time could be improved upon by only rebuilding libraries that have changes.

## Writing tests

In the `src` directory, you will find utility files and spec files, where spec files contain tests and utilities provide the functions in which we can test SSR. A typical test is comprised on setting up a server instance that runs the application in SSR mode and a proxy server instance for manipulating tests to and from the backend. By manipulating requests and responses between the application and the backend API using the proxy server, we can test that our application behaves correctly in different scenarios.

The utilities described below will help you to write an SSR test.

### ssr.utils.ts

Contains the methods we need to start and end our SSR servers. We will typically use these to start a server instance at the start of a test and kill that instance at the end of our test.

### proxy.utils.ts

In addition to our server instance, we will start a proxy server instance in order to intercept requests going to and from the backend. It is a bridge between a typical Spartacus application and the API backend and is used to manipulate requests for testing purposes. For example, we may delay a request in order to test how the server responds to a timeout. Or we could change a status code to 500.

### log.utils.ts

We can use log utilities in order to verify the messages that are generated in the server log. This can help us to assert that the server has done what we think it should do (eg. rendering completion, fallback to CSR, etc).

## Other Notes

- We exclude running SSR tests with application unit tests because they require a unique setup to run in a way we can test SSR.
34 changes: 34 additions & 0 deletions projects/ssr-tests/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const { pathsToModuleNameMapper } = require('ts-jest');
const { compilerOptions } = require('./tsconfig.json');
const { defaultTransformerOptions } = require('jest-preset-angular/presets');

/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */
module.exports = {
preset: 'jest-preset-angular',
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, {
prefix: '<rootDir>/',
}),
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testMatch: ['**/+(*.)+(spec).+(ts)'],
transform: {
'^.+\\.(ts|js|mjs|html|svg)$': [
'jest-preset-angular',
{
...defaultTransformerOptions,
tsconfig: '<rootDir>/tsconfig.json',
},
],
},

collectCoverage: false,
coverageReporters: ['json', 'lcov', 'text', 'clover'],
coverageDirectory: '<rootDir>/../../coverage/ssr-tests',
coverageThreshold: {
global: {
statements: 90,
branches: 74,
functions: 90,
lines: 90,
},
},
};
21 changes: 21 additions & 0 deletions projects/ssr-tests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "ssr-tests",
"description": "Spartacus SSR Tests",
"keywords": [
"spartacus",
"ssr",
"tests"
],
"author": "SAP, Spartacus team",
"license": "Apache-2.0",
"private": true,
"scripts": {
"test": "../../node_modules/.bin/jest --config ./jest.config.js"
},
"dependencies": {
"tslib": "^2.6.2"
},
"peerDependencies": {
"http-proxy": "^1.18.1"
}
}
22 changes: 22 additions & 0 deletions projects/ssr-tests/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "ssr-tests",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "projects/ssr-tests/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["projects/ssr-tests/**/*.ts"]
}
},
"test-jest": {
"executor": "nx:run-commands",
"options": {
"command": "npm run test --verbose",
"cwd": "projects/ssr-tests"
}
}
},
"tags": ["type:util"]
}
9 changes: 9 additions & 0 deletions projects/ssr-tests/setup-jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/

import 'jest-preset-angular/setup-jest';
import 'zone.js';
86 changes: 86 additions & 0 deletions projects/ssr-tests/src/log.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Contains methods pertaining to reading, writing and asserting of the ssr log
* generated by a running ssr server for the sake of testing ssr.
*/

import * as fs from 'fs';

/**
* Path where SSR log file from server will be generated and read from.
*/
const SSR_LOG_PATH = './.ssr.log';

/**
* Writes no characters to log to clear log file.
*/
export function clearSsrLogFile(): void {
fs.writeFileSync(SSR_LOG_PATH, '');
}

/**
* Returns all text in the log as a single string.
*/
export function getLogText(): string {
return fs.readFileSync(SSR_LOG_PATH).toString();
}

/**
* Reads log and returns messages as string array.
*/
export function getLogMessages(): string[] {
const data = fs.readFileSync(SSR_LOG_PATH).toString();
return (
data
.toString()
.split('\n')
// We're interested only in JSON logs from Spartacus SSR app.
// We ignore plain text logs coming from other sources, like `Node Express server listening on http://localhost:4200`
.filter((text: string) => text.charAt(0) === '{')
.map((text: any) => JSON.parse(text).message)
);
}

/**
* Check that log contains expected messages in string array.
* Fail test if log does not contain expected messages.
*/
export function assertMessages(expected: string[]): void {
const messages = getLogMessages();
for (const message of expected) {
expect(messages).toContain(message);
}
}

/**
* Check log every interval to see if log contains text.
* Keeps waiting until log contains text or test times out.
*/
export async function waitUntilLogContainsText(
text: string,
checkInterval = 500
): Promise<true> {
return new Promise((resolve) => {
if (doesLogContainText(text)) {
return resolve(true);
}
return setTimeout(
() => resolve(waitUntilLogContainsText(text)),
checkInterval
);
});
}

/**
* Returns true if log contains string.
*/
export function doesLogContainText(text: string): boolean {
const data = fs.readFileSync(SSR_LOG_PATH).toString();
return data.includes(text);
}
86 changes: 86 additions & 0 deletions projects/ssr-tests/src/proxy.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/

import * as http from 'http';
import * as httpProxy from 'http-proxy';

const proxy = (<any>httpProxy).createProxyServer({ secure: false });

/**
* Default settings to send http requests.
*/
const REQUEST_OPTIONS = {
host: 'localhost',
port: 4000,
};

interface ProxyOptions {
/**
* The url to reroute requests to.
*/
target: string;
/**
* Number of seconds to delay requests before sending.
*/
delay?: number;
/**
* Number of status code to set response to.
*/
throwStatus?: number;
}

/**
* Starts an http proxy server on port 9002 with the provided options.
*/
export async function startProxyServer(options: ProxyOptions) {
return new Promise((resolve) => {
const server = http.createServer((req: any, res: any) => {
const forwardRequest = () =>
proxy.web(req, res, { target: options.target });

if (options.throwStatus) {
proxy.on('proxyRes', (proxyRes: any) => {
proxyRes.statusCode = options.throwStatus;
});
}

if (options.delay) {
setTimeout(forwardRequest, options.delay);
} else {
forwardRequest();
}
});

server.listen(9002, () => {
resolve(server);
});
});
}

/**
* Send an http GET request to a given url.
*/
export async function sendRequest(path: string) {
Zeyber marked this conversation as resolved.
Show resolved Hide resolved
return new Promise((resolve, reject) => {
const req = http.get({ ...REQUEST_OPTIONS, path }, (res: any) => {
const bodyChunks: string[] = [];

res
.on('data', (chunk: any) => {
bodyChunks.push(chunk);
})
.on('end', () => {
res.bodyChunks = bodyChunks;
return resolve(res);
});
});

req.on('error', (e: Error) => {
reject(e);
});
});
}
Loading
Loading