Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop' into release-2211.35.0-1
Browse files Browse the repository at this point in the history
  • Loading branch information
rmch91 committed Jan 28, 2025
2 parents b3c1841 + 0ab6454 commit aa747ce
Show file tree
Hide file tree
Showing 6 changed files with 511 additions and 18 deletions.
17 changes: 17 additions & 0 deletions projects/schematics/src/migrations/2211_35/ssr/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/

import { noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { checkIfSSRIsUsedWithApplicationBuilder } from '../../../shared/utils/package-utils';
import { updateServerFile } from './update-ssr/update-server-files';

export function migrate(): Rule {
return (tree: Tree, _context: SchematicContext) => {
return checkIfSSRIsUsedWithApplicationBuilder(tree)
? updateServerFile()
: noop();
};
}
147 changes: 147 additions & 0 deletions projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import { join } from 'path';
import * as shared from '../../../shared/utils/package-utils';

jest.mock('../../../shared/utils/package-utils', () => ({
...jest.requireActual('../../../shared/utils/package-utils'),
checkIfSSRIsUsedWithApplicationBuilder: jest.fn(),
}));

const collectionPath = join(__dirname, '../../migrations.json');
const MIGRATION_SCRIPT_NAME = '01-migration-v2211_35-ssr';

describe('Update SSR Migration', () => {
let tree: Tree;
let runner: SchematicTestRunner;

const workspaceContent = {
version: 1,
projects: {
app: {
root: '',
architect: {
build: {
builder: '@angular-devkit/build-angular:application',
},
},
},
},
};

const serverFileContent = `
import { APP_BASE_HREF } from '@angular/common';
import {
NgExpressEngineDecorator,
defaultExpressErrorHandlers,
ngExpressEngine as engine,
} from '@spartacus/setup/ssr';
import express from 'express';
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import AppServerModule from './main.server';
const ngExpressEngine = NgExpressEngineDecorator.get(engine, {
ssrFeatureToggles: {
avoidCachingErrors: true,
},
});
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(browserDistFolder, 'index.html');
const indexHtmlContent = readFileSync(indexHtml, 'utf-8');
}
`;

beforeEach(() => {
tree = Tree.empty();
runner = new SchematicTestRunner('migrations', collectionPath);
jest.resetAllMocks();
tree.create('/angular.json', JSON.stringify(workspaceContent));
});

it.each(['/server.ts', '/src/server.ts'])(
'should update %s when using application builder and SSR is used',
async (filePath) => {
(
shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock
).mockReturnValue(true);
tree.create(filePath, serverFileContent);

const newTree = await runner.runSchematic(
MIGRATION_SCRIPT_NAME,
{},
tree
);
const content = newTree.readText(filePath);

expect(content).toContain('export function app()');
expect(content).toContain("join(serverDistFolder, 'index.server.html')");
expect(content).not.toContain("join(browserDistFolder, 'index.html')");
expect(
shared.checkIfSSRIsUsedWithApplicationBuilder
).toHaveBeenCalledWith(tree);
}
);

it('should not update when SSR is not used', async () => {
(
shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock
).mockReturnValue(false);
tree.create('/server.ts', serverFileContent);

const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree);
const content = newTree.readText('/server.ts');

expect(content).toContain("join(browserDistFolder, 'index.html')");
expect(content).not.toContain(
"join(serverDistFolder, 'index.server.html')"
);
expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith(
tree
);
});

it('should handle missing server.ts file', async () => {
(
shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock
).mockReturnValue(true);

const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree);

expect(newTree.exists('/server.ts')).toBe(false);
expect(newTree.exists('/src/server.ts')).toBe(false);
expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith(
tree
);
});

it('should preserve other join statements when SSR is used', async () => {
(
shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock
).mockReturnValue(true);

const contentWithMultipleJoins = `
const otherFile = join(process.cwd(), 'other.html');
const indexHtml = join(browserDistFolder, "index.html");
const anotherFile = join(process.cwd(), 'another.html');
`;

tree.create('/server.ts', contentWithMultipleJoins);

const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree);
const content = newTree.readText('/server.ts');

expect(content).toContain("join(process.cwd(), 'other.html')");
expect(content).toContain('join(serverDistFolder, "index.server.html")');
expect(content).toContain("join(process.cwd(), 'another.html')");
expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith(
tree
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/

import { normalize } from '@angular-devkit/core';
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import * as ts from 'typescript';

/**
* Finds the server.ts file in the project.
* Checks both root and src directory locations.
*/
function findServerFile(tree: Tree): string | null {
const possiblePaths = ['./server.ts', './src/server.ts'];

for (const path of possiblePaths) {
if (tree.exists(normalize(path))) {
return path;
}
}
return null;
}

/**
* Determines if a string literal uses single or double quotes.
* @param node - The string literal node to check
* @returns true for single quotes, false for double quotes
*/
function getQuotePreference(node: ts.StringLiteral): boolean {
return node.getText().startsWith("'");
}

interface JoinCallResult {
isMatch: boolean;
quotePreference?: boolean;
}

/**
* Checks if a node is a join() call with 'index.html' as the last argument.
* Also determines the quote style used in the original code.
*/
function isJoinCallWithIndexHtml(node: ts.Node): JoinCallResult {
if (!ts.isCallExpression(node)) {
return { isMatch: false };
}

if (!ts.isIdentifier(node.expression) || node.expression.text !== 'join') {
return { isMatch: false };
}

const lastArg = node.arguments[node.arguments.length - 1];
if (!ts.isStringLiteral(lastArg)) {
return { isMatch: false };
}

if (lastArg.text === 'index.html') {
return {
isMatch: true,
quotePreference: getQuotePreference(lastArg),
};
}

return { isMatch: false };
}

/**
* Visits the node and tries to find recursively the indexHtml const.
* When found, it updates the indexHtml const to use serverDistFolder and index.server.html.
*/
function visitNodeToUpdateIndexHtmlConst(
node: ts.Node,
content: string
): string {
let updatedContent = content;

if (ts.isVariableStatement(node)) {
const declarations = node.declarationList.declarations;
if (declarations.length === 1) {
const declaration = declarations[0];
if (
ts.isIdentifier(declaration.name) &&
declaration.name.text === 'indexHtml' &&
declaration.initializer
) {
const { isMatch, quotePreference } = isJoinCallWithIndexHtml(
declaration.initializer
);
if (isMatch) {
const originalText = node.getText();
const quote = quotePreference ? "'" : '"';
const newText = `const indexHtml = join(serverDistFolder, ${quote}index.server.html${quote});`;
updatedContent = updatedContent.replace(originalText, newText);
}
}
}
}

// Recursively visit all children
ts.forEachChild(node, (childNode) => {
updatedContent = visitNodeToUpdateIndexHtmlConst(childNode, updatedContent);
});

return updatedContent;
}

/**
* Creates a rule that updates the server.ts file to use index.server.html
* instead of index.html when using the application builder.
*/
export function updateServerFile(): Rule {
return (tree: Tree, context: SchematicContext) => {
context.logger.info('🔍 Checking if server.ts needs to be updated...');
const serverFilePath = findServerFile(tree);
if (!serverFilePath) {
context.logger.warn('🔍 Could not find server.ts file - skipping update');
return tree;
}

context.logger.info(
`🔄 Updating ${serverFilePath} to use index.server.html`
);

const fileContentBuffer = tree.read(serverFilePath);
if (!fileContentBuffer) {
context.logger.warn(
`⚠️ Could not read ${serverFilePath} - skipping update`
);
return tree;
}

const content = fileContentBuffer.toString('utf-8');
const sourceFile = ts.createSourceFile(
'server.ts',
content,
ts.ScriptTarget.Latest,
true
);

const updatedContent = visitNodeToUpdateIndexHtmlConst(sourceFile, content);

if (updatedContent !== content) {
tree.overwrite(serverFilePath, updatedContent);
context.logger.info(`✅ Successfully updated ${serverFilePath}`);
}
return tree;
};
}
5 changes: 5 additions & 0 deletions projects/schematics/src/migrations/migrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@
"version": "2211.19.0",
"factory": "./2211_19/angular-json-styles/angular-json-styles#migrate",
"description": "Update the angular.json with the style preprocessor options"
},
"01-migration-v2211_35-ssr": {
"version": "2211.35.0",
"factory": "./2211_35/ssr/ssr#migrate",
"description": "Updates server.ts file to use dist/server/index.server.ts"
}
}
}
Loading

0 comments on commit aa747ce

Please sign in to comment.