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

[POC] Testing utils for SSR Error Handling #19102

Open
wants to merge 57 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
260f4df
fix: 'this' is undefined in ProductEffects.productLoadEffect (#17594)
Platonn Jun 30, 2023
0357962
feat: handle http errors in ssr (#17624)
kpawelczak Jul 25, 2023
038ffdb
feat: update optimized ssr engine tests (#17728)
kpawelczak Aug 7, 2023
8bba7a6
feat: created effect for handling ngrx errors (#17657)
kpawelczak Aug 22, 2023
61f9364
Merge branch 'develop-6.5.x' into epic/ssr-error-handling
kpawelczak Sep 27, 2023
4c7c665
Unit test fixes (#17879)
kpawelczak Sep 27, 2023
db9157b
feat: CXSPA-3781 SSR - Multi-provided error interceptors (#17865)
pawelfras Sep 28, 2023
32d82d6
chore: Enable cache build check on epic branches (#17938)
Zeyber Oct 9, 2023
77e8d8d
fix build failure (#17944)
kpawelczak Oct 10, 2023
e432c0a
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Mar 12, 2024
5e09980
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Mar 12, 2024
6074bce
remove test.json files
pawelfras Mar 25, 2024
0e57246
feat: CXSPA-6575 Introduce MULTI_ERROR_HANDLERS to CxErrorHandler (#1…
pawelfras Mar 26, 2024
1a2eb6e
chore: cleanup
pawelfras Apr 15, 2024
ce632d9
feat: CXSPA-6576 Introduce SERVER_ERROR_RESPONSE_FACTORY (#18683)
pawelfras Apr 16, 2024
f280662
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Apr 18, 2024
af10432
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Apr 23, 2024
1c3171a
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Apr 24, 2024
293e579
refactor: CXSPA-6598 Remove unused error interceptors and rename MULT…
pawelfras Apr 29, 2024
e578194
feat: CXSPA-6578 Introduce PROPAGATE_SERVER_ERROR_RESPONSE and defaul…
pawelfras Apr 29, 2024
082a459
chore: CXSPA-6960 Remove 2023 license headers (#18800)
pawelfras May 6, 2024
3dbed5a
feat: CXSPA-7197 Create toggle for strict NgRx and HTTP error handlin…
pawelfras Jun 27, 2024
fbc1bc3
fix: CXSPA-7474 Move error handling logic to ExpressJS error handlers…
pawelfras Jul 2, 2024
065794d
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Jul 5, 2024
91de035
fix prettier issue and unit test
pawelfras Jul 5, 2024
390f50d
feat: CXSPA-6941 Provide SSR Error Handling in schematics for fresh a…
pawelfras Jul 8, 2024
ab7c3be
chore: CXSPA-7716 Refactor error handling using common error determin…
pawelfras Jul 8, 2024
48f39f0
Revert "feat: update optimized ssr engine tests (#17728)" (#19033)
pawelfras Jul 9, 2024
84de0d1
refactor: revert breaking changes made in #17657 (#19036)
Platonn Jul 11, 2024
51a859d
feat: CXSPA-6890 Create toggle and optimization options for propagati…
pawelfras Jul 15, 2024
031ec7d
feat: ESLint rule - all ngrx "Fail" actions should have `implements E…
Platonn Jul 17, 2024
a1979bc
chore: CXSPA-7900 Adjust schematics to enable `avoidCachingErrors` op…
pawelfras Jul 23, 2024
f3348d3
add utils for testing SSR error handling
pawelfras Jul 25, 2024
81acc0a
add support for custom response status and runtime error in component
pawelfras Jul 31, 2024
f1cc528
Add license header
github-actions[bot] Aug 2, 2024
f16b8e1
update TestQueryParams props description
pawelfras Aug 5, 2024
c6d3a7a
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Aug 29, 2024
eeb23f0
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Sep 3, 2024
e0356b9
fix: linting issues
pawelfras Sep 3, 2024
253858e
fix: circular dependency in core lib
pawelfras Sep 3, 2024
177d92b
fix: unit test for product action
pawelfras Sep 3, 2024
ab5f6c8
chore: refactor after review
pawelfras Sep 3, 2024
69ad454
Trigger Build
pawelfras Sep 3, 2024
e6b9edc
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Sep 3, 2024
4bb832c
fix: adjust tests for CxErrorHandler
pawelfras Sep 3, 2024
c37d81d
chore: adjust schematics and snapshots
pawelfras Sep 3, 2024
829f2f6
feat: CXSPA-8273 SSR Error Handling - E2E tests (#19197)
pawelfras Sep 4, 2024
68cab55
refactor after review
pawelfras Sep 4, 2024
8b2a17b
remove unnecessary comment
pawelfras Sep 4, 2024
e9b6604
remove unnecessary part of JSDoc
pawelfras Sep 4, 2024
63c15db
fix typo
pawelfras Sep 4, 2024
f5c8343
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Sep 4, 2024
3f72a4b
chore: move ErrorAction to error-handling directory
pawelfras Sep 4, 2024
c178d01
Merge branch 'develop' into epic/ssr-error-handling
pawelfras Sep 4, 2024
de6736e
feat: CXSPA-8308 SSR Error Handling - run E2E tests on CI pipeline (#…
pawelfras Sep 5, 2024
7eba698
refactor: change error type from to in ErrorAction interface
pawelfras Sep 5, 2024
62d6137
Merge branch 'epic/ssr-error-handling' into feat/CXSPA-6940
pawelfras Sep 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@
"@nx/workspace/use-provide-default-feature-toggles": "error",
"@nx/workspace/use-provide-default-feature-toggles-factory": "error"
}
},
{
"files": ["*.action*.ts"],
"rules": {
"@nx/workspace/no-ngrx-fail-action-without-error-action-implementation": "error"
}
}
]
}
2 changes: 1 addition & 1 deletion .github/workflows/ci-merge-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ jobs:
run: |
ci-scripts/e2e-cypress.sh -s b2b
merge_checks_result:
needs: [b2c_e2e_tests, b2c_ssr_e2e_tests, b2b_e2e_tests]
needs: [b2c_e2e_tests, b2c_ssr_e2e_tests, b2b_e2e_tests, ssr_tests]
name: MC - Result
runs-on: ubuntu-latest
if: ${{ always() }}
Expand Down
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,32 @@ jobs:
BUILD_NUMBER: ci-build-number-${{ github.event.pull_request.head.sha || github.run_id }}
run: |
ci-scripts/e2e-cypress.sh -s b2b
ssr_tests:
needs: [no_retries, validate_e2e_execution]
name: SSR Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v2
with:
path: |
node_modules
key: nodemodules-${{ github.event.pull_request.base.sha }}
restore-keys: nodemodules-${{ github.event.pull_request.base.sha }}
- name: Package installation
run: npm ci
- name: Build SSR Server
run: npm run build:libs && npm run build && npm run build:ssr:local-http-backend
- name: Run SSR tests
run: npm run test:ssr:ci --verbose
build_conclusion:
needs: [no_retries, unit_tests, linting, b2c_e2e_tests, b2c_ssr_e2e_tests, b2b_e2e_tests, sonarqube_scan]
needs: [no_retries, unit_tests, linting, b2c_e2e_tests, b2c_ssr_e2e_tests, b2b_e2e_tests, ssr_tests, sonarqube_scan]
name: Build Conclusion
runs-on: ubuntu-latest
if: ${{ always() }}
Expand Down
3 changes: 2 additions & 1 deletion core-libs/setup/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"dest": "../../dist/setup",
"lib": {
"entryFile": "public_api.ts"
}
},
"assets": ["**/*.ejs"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CxCommonEngine should handle APP_INITIALIZER errors the standard Angular way and throw if any occurred 1`] = `
"R3InjectorError(TokenServerModule)[InjectionToken SOME_TOKEN -> InjectionToken SOME_TOKEN]:
NullInjectorError: No provider for InjectionToken SOME_TOKEN!"
`;

exports[`CxCommonEngine should handle errors propagated from SSR 1`] = `"test error"`;

exports[`CxCommonEngine should not override providers passed to options 1`] = `"<html data-critters-container><head></head><body><cx-token ng-version="17.0.5" ng-server-context="ssr">message:test</cx-token></body></html>"`;

exports[`CxCommonEngine should return html if no errors 1`] = `"<html data-critters-container><head></head><body><cx-mock ng-version="17.0.5" ng-server-context="ssr">some template</cx-mock></body></html>"`;
130 changes: 130 additions & 0 deletions core-libs/setup/ssr/engine/cx-common-engine.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// eslint-disable-next-line import/no-unassigned-import
import '@angular/compiler';

import { Component, InjectionToken, NgModule, inject } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ServerModule } from '@angular/platform-server';
import { PROPAGATE_ERROR_TO_SERVER } from '../error-handling/error-response/propagate-error-to-server';
import { CxCommonEngine } from './cx-common-engine';

// Test how the CxCommonEngine handles successful server-side rendering
@Component({ selector: 'cx-mock', template: 'some template' })
export class SuccessComponent {}

@NgModule({
imports: [BrowserModule, ServerModule],
declarations: [SuccessComponent],
bootstrap: [SuccessComponent],
})
export class SuccessServerModule {}

// Test how the CxCommonEngine handles propagated error
@Component({
selector: 'cx-response',
template: ``,
})
export class WithPropagatedErrorComponent {
constructor() {
inject(PROPAGATE_ERROR_TO_SERVER)(new Error('test error'));
}
}

@NgModule({
imports: [BrowserModule, ServerModule],
declarations: [WithPropagatedErrorComponent],
bootstrap: [WithPropagatedErrorComponent],
})
export class WithPropagatedErrorServerModule {}

// Test that the CxCommonEngine doesn't override providers
// If SOME_TOKEN not provided, test how the CxCommonEngine handles APP_INITIALIZER errors
export const SOME_TOKEN = new InjectionToken<string>('SOME_TOKEN');

@Component({
selector: 'cx-token',
template: `message:{{ someToken }}`,
})
export class TokenComponent {
someToken = inject(SOME_TOKEN);
}

@NgModule({
imports: [BrowserModule, ServerModule],
declarations: [TokenComponent],
bootstrap: [TokenComponent],
})
export class TokenServerModule {}

describe('CxCommonEngine', () => {
let engine: CxCommonEngine;

beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'log').mockImplementation();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should return html if no errors', async () => {
engine = new CxCommonEngine({
bootstrap: SuccessServerModule,
});

const html = await engine.render({
url: 'http://localhost:4200',
document: '<cx-mock></cx-mock>',
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
expect(html).toMatchSnapshot();
});

it('should not override providers passed to options', async () => {
engine = new CxCommonEngine({
bootstrap: TokenServerModule,
});

const html = await engine.render({
url: 'http://localhost:4200',
document: '<cx-token></cx-token>',
providers: [{ provide: SOME_TOKEN, useValue: 'test' }],
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
expect(html).toMatchSnapshot();
});

it('should handle APP_INITIALIZER errors the standard Angular way and throw if any occurred', async () => {
engine = new CxCommonEngine({
bootstrap: TokenServerModule,
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
await expect(
engine.render({
url: 'http://localhost:4200',
document: '<cx-token></cx-token>',
})
).rejects.toThrowErrorMatchingSnapshot();
});

it('should handle errors propagated from SSR', async () => {
engine = new CxCommonEngine({
bootstrap: WithPropagatedErrorServerModule,
});

// Cannot use `.toMatchInlineSnapshot()` due to bug in jest:
// see: https://github.com/thymikee/jest-preset-angular/issues/1084
await expect(
engine.render({
url: 'http://localhost:4200',
document: '<cx-response></cx-response>',
})
).rejects.toThrowErrorMatchingSnapshot();
});
});
67 changes: 67 additions & 0 deletions core-libs/setup/ssr/engine/cx-common-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import {
CommonEngine,
CommonEngineOptions,
CommonEngineRenderOptions,
} from '@angular/ssr';
import { PROPAGATE_ERROR_TO_SERVER } from '../error-handling/error-response/propagate-error-to-server';

/**
* The Spartacus extension of the Angular's `CommonEngine`. It is able to handle the propagated server responses caught during server-side rendering of a Spartacus app.
* For reference, see Angular's source code: https://github.com/angular/angular-cli/blob/6cf866225ab09f8b4b3803c000b632bed8448ce4/packages/angular/ssr/src/common-engine.ts#L56
*
* @extends {CommonEngine}
*/
export class CxCommonEngine extends CommonEngine {
constructor(options?: CommonEngineOptions) {
super(options);
}

/**
* @override
* Renders for the given options.
* If an error is populated from the rendered applications
* (via `PROPAGATE_ERROR_TO_SERVER` callback), then such an error
* will be thrown and the result promise rejected - but only AFTER the rendering is complete.
* In other words, at first an error occurs and it's captured, then we wait until the rendering completes
* and ONLY then we reject the promise with the payload being the encountered error.
*
* Note: if more errors are captured during the rendering, only the first one will be used
* as the payload of the rejected promise, others won't.
*
* @param {CommonEngineRenderOptions} options - The options to render.
* @returns {Promise<string>} Promise which resolves with the rendered HTML as a string
* OR rejects with the error, if any is propagated from the rendered app.
*/
override async render(options: CommonEngineRenderOptions): Promise<string> {
let error: undefined | unknown;

return super
.render({
...options,
providers: [
{
provide: PROPAGATE_ERROR_TO_SERVER,
useFactory: () => {
return (propagatedError: unknown) => {
// We're interested only the first propagated error, so we use `??=` instead of `=`:
error ??= propagatedError;
};
},
},
...(options.providers ?? []),
],
})
.then((html: string) => {
if (error) {
throw error;
}
return html;
});
}
}
2 changes: 1 addition & 1 deletion core-libs/setup/ssr/engine/ng-express-engine.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <[email protected]>
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/
Expand Down
9 changes: 3 additions & 6 deletions core-libs/setup/ssr/engine/ng-express-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@
*/

import { StaticProvider } from '@angular/core';
import {
CommonEngine,
CommonEngineOptions,
CommonEngineRenderOptions,
} from '@angular/ssr';
import { CommonEngineOptions, CommonEngineRenderOptions } from '@angular/ssr';
import { Request, Response } from 'express';
import { REQUEST, RESPONSE } from '../tokens/express.tokens';
import { CxCommonEngine } from './cx-common-engine';

/**
* @license
Expand Down Expand Up @@ -80,7 +77,7 @@ export interface RenderOptions extends CommonEngineRenderOptions {
* - https://github.com/angular/universal/blob/e798d256de5e4377b704e63d993dc56ea35df97d/modules/express-engine/src/main.ts
*/
export function ngExpressEngine(setupOptions: NgSetupOptions) {
const engine = new CommonEngine({
const engine = new CxCommonEngine({
bootstrap: setupOptions.bootstrap,
providers: setupOptions.providers,
enablePerformanceProfiler: setupOptions.enablePerformanceProfiler,
Expand Down
7 changes: 7 additions & 0 deletions core-libs/setup/ssr/error-handling/error-response/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

export * from './propagate-error-to-server';
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0
*/

import { InjectionToken } from '@angular/core';

/**
* Propagates the given error object to the higher layer in the server.
*
* It's meant to propagate errors for example to ExpressJS layer when using SSR
* or to a Prerendering Worker when using Server Prerendering.
* Currently, it's provided OOTB only in SSR (not prerendering), in the `CxCommonEngine` class.
*
* Note: We need it until Angular implements a proper propagation of async errors
* from an app to the the higher layer in the server.
* For more, see the Angular issue https://github.com/angular/angular/issues/33642
*/
export const PROPAGATE_ERROR_TO_SERVER = new InjectionToken<
(error: unknown) => void
>('PROPAGATE_ERROR_RESPONSE');
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { HttpErrorResponse } from '@angular/common/http';
import { CmsPageNotFoundOutboundHttpError } from '@spartacus/core';
import { defaultExpressErrorHandlers } from './express-error-handlers';

describe('errorResponseHandlers', () => {
let documentContent: string;
let req: any;
let res: any;
let next: any;

beforeEach(() => {
documentContent = 'some document content';
req = {};
res = {
set: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
});

it('should do nothing if headers are already sent', () => {
const err = new HttpErrorResponse({
error: 'Page not found',
});
const errorRequestHandler = defaultExpressErrorHandlers(documentContent);
res.headersSent = true;

errorRequestHandler(err, req, res, next);

expect(res.set).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.send).not.toHaveBeenCalled();
});

it('should handle CmsPageNotFoundOutboundHttpError', () => {
const err = new CmsPageNotFoundOutboundHttpError('Page not found');
const errorRequestHandler = defaultExpressErrorHandlers(documentContent);

errorRequestHandler(err, req, res, next);

expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith(documentContent);
});

it('should handle unknown error', () => {
const err = new Error('unknown error');
const errorRequestHandler = defaultExpressErrorHandlers(documentContent);

errorRequestHandler(err, req, res, next);

expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(res.status).toHaveBeenCalledWith(500);
expect(res.send).toHaveBeenCalledWith(documentContent);
});
});
Loading
Loading