chore: copia de libs/shared con prefijo Pi y APIs de terceros actualizadas

This commit is contained in:
2026-07-27 21:14:48 +02:00
parent 0758b00d54
commit 771d83c0aa
672 changed files with 47352 additions and 4692 deletions

View File

@@ -1,22 +0,0 @@
# This file is generated by Nx.
# Build the docker image with `npx nx docker:build api`.
# Tip: Modify "docker:build" options in project.json to change docker build args.
#
# Run the container with `nx docker:run api -p 3000:3000`.
#
FROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0
ENV PORT=3333
WORKDIR /app
COPY dist .
# You can remove this install step if you build with `--bundle` option.
# The bundled output will include external dependencies.
RUN npm --omit=dev -f install
CMD [ "node", "main.js" ]

View File

@@ -1,3 +0,0 @@
import baseConfig from '../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -1,73 +0,0 @@
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"tags": ["scope:api"],
"release": {
"docker": {
"repositoryName": "org/angular-template-api"
}
},
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"platform": "node",
"outputPath": "apps/api/dist",
"format": ["cjs"],
"bundle": false,
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"generatePackageJson": true,
"esbuildOptions": {
"sourcemap": true,
"outExtension": {
".js": ".js"
}
}
},
"configurations": {
"development": {},
"production": {
"generateLockfile": true,
"esbuildOptions": {
"sourcemap": false,
"outExtension": {
".js": ".js"
}
}
}
}
},
"serve": {
"continuous": true,
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"dependsOn": ["build"],
"options": {
"buildTarget": "api:build",
"runBuildTargetDependencies": false
},
"configurations": {
"development": {
"buildTarget": "api:build:development"
},
"production": {
"buildTarget": "api:build:production"
}
}
},
"test": {
"options": {
"passWithNoTests": true
}
},
"docker:build": {
"dependsOn": ["build"]
}
}
}

View File

@@ -1,139 +0,0 @@
import express from 'express';
import { ProductsService } from '@org/api/products';
import { ApiResponse, Product, ProductFilter, PaginatedResponse } from '@org/models';
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ? Number(process.env.PORT) : 3333;
const app = express();
const productsService = new ProductsService();
// Middleware
app.use(express.json());
// CORS configuration for Angular app
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
app.get('/', (req, res) => {
res.send({ message: 'Hello API' });
});
// Products endpoints
app.get('/api/products', (req, res) => {
try {
const filter: ProductFilter = {};
if (req.query.category) {
filter.category = req.query.category as string;
}
if (req.query.minPrice) {
filter.minPrice = Number(req.query.minPrice);
}
if (req.query.maxPrice) {
filter.maxPrice = Number(req.query.maxPrice);
}
if (req.query.inStock !== undefined) {
filter.inStock = req.query.inStock === 'true';
}
if (req.query.searchTerm) {
filter.searchTerm = req.query.searchTerm as string;
}
const page = req.query.page ? Number(req.query.page) : 1;
const pageSize = req.query.pageSize ? Number(req.query.pageSize) : 12;
const result = productsService.getAllProducts(filter, page, pageSize);
const response: ApiResponse<PaginatedResponse<Product>> = {
data: result,
success: true,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
data: null,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
res.status(500).json(response);
}
});
app.get('/api/products/:id', (req, res) => {
try {
const product = productsService.getProductById(req.params.id);
if (!product) {
const response: ApiResponse<null> = {
data: null,
success: false,
error: 'Product not found',
};
return res.status(404).json(response);
}
const response: ApiResponse<Product> = {
data: product,
success: true,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
data: null,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
res.status(500).json(response);
}
});
app.get('/api/products-metadata/categories', (req, res) => {
try {
const categories = productsService.getCategories();
const response: ApiResponse<string[]> = {
data: categories,
success: true,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
data: null,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
res.status(500).json(response);
}
});
app.get('/api/products-metadata/price-range', (req, res) => {
try {
const priceRange = productsService.getPriceRange();
const response: ApiResponse<{ min: number; max: number }> = {
data: priceRange,
success: true,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
data: null,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
res.status(500).json(response);
}
});
app.listen(port, host, () => {
console.log(`[ ready ] http://${host}:${port}`);
});

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -1,16 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true
}
}

View File

@@ -1,28 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@@ -1,22 +0,0 @@
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/api',
plugins: [tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
test: {
name: 'api',
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
passWithNoTests: true,
coverage: {
reportsDirectory: '../../coverage/apps/api',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,12 +0,0 @@
import playwright from 'eslint-plugin-playwright';
import baseConfig from '../../eslint.config.mjs';
export default [
playwright.configs['flat/recommended'],
...baseConfig,
{
files: ['**/*.ts', '**/*.js'],
// Override or add rules here
rules: {},
},
];

View File

@@ -1,70 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
// For CI, you may want to set BASE_URL to the deployed application.
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'npx nx run shop:serve',
url: 'http://localhost:4200',
reuseExistingServer: true,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Uncomment to test wider variety of browsers
// Note, you will also need to update `.github/workflows/ci.yml`
// Replacing `npx playwright install --only-shell` with `npx playwright install --with-deps`
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
}, */
// Uncomment for branded browsers
/* {
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
} */
],
});

View File

@@ -1,8 +0,0 @@
{
"name": "shop-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/shop-e2e/src",
"implicitDependencies": ["shop"],
"targets": {}
}

View File

@@ -1,162 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Navigation and User Flow', () => {
test('should complete a full user journey through the app', async ({ page }) => {
// Start at home page
await page.goto('/');
// Should redirect to products
await page.waitForURL('**/products');
// Verify we're on products page
const productsHeading = page.locator('h1:has-text("Our Products")');
await expect(productsHeading).toBeVisible();
// Search for a specific product
const searchInput = page.locator('input[placeholder*="Search"]');
await searchInput.fill('Product 1');
await page.waitForFunction(() => document.querySelectorAll('[class*="product-card"]').length > 0);
// Click on the first search result
const firstResult = page.locator('[class*="product-card"]').first();
const productName = await firstResult.locator('h3').textContent();
await firstResult.click();
// Should navigate to product detail
await page.waitForURL('**/products/**');
// Verify product detail page
const detailProductName = page.locator('h1').filter({ hasText: productName || '' });
await expect(detailProductName).toBeVisible();
// Click back to products
const backLink = page.locator('a:has-text("Back to Products")');
await backLink.click();
// Should be back on products page
await page.waitForURL('**/products');
await expect(productsHeading).toBeVisible();
// The search should be cleared
await expect(searchInput).toHaveValue('');
});
test('should handle navigation via header link', async ({ page }) => {
// Start on a product detail page
await page.goto('/products/prod-1');
await page.waitForLoadState('domcontentloaded');
// Click the Products link in the header
const headerProductsLink = page.locator('nav a:has-text("Products")');
await headerProductsLink.click();
// Should navigate to products listing
await page.waitForURL('**/products');
// Verify products page is loaded
const productsGrid = page.locator('[class*="product"]').first();
await expect(productsGrid).toBeVisible();
});
test('should maintain filter state during navigation', async ({ page }) => {
// Go to products page
await page.goto('/products');
await page.waitForLoadState('domcontentloaded');
// Apply filters
const categoryDropdown = page.locator('select');
await categoryDropdown.selectOption('Electronics');
const searchInput = page.locator('input[placeholder*="Search"]');
await searchInput.fill('Product');
// Wait for filtered results
await page.waitForFunction(() => document.querySelectorAll('[class*="product-card"]').length > 0);
// Navigate to a product
const product = page.locator('[class*="product-card"]').first();
await product.click();
// Wait for product detail page
await page.waitForURL('**/products/**');
// Go back using browser back button
await page.goBack();
// Filters should be reset (this is the expected behavior in most SPAs)
// The search and category should be cleared
await expect(searchInput).toHaveValue('');
await expect(categoryDropdown).toHaveValue('');
});
test('should handle rapid navigation', async ({ page }) => {
// Navigate to products
await page.goto('/products');
// Quickly click multiple products
for (let i = 0; i < 3; i++) {
const product = page.locator('[class*="product-card"]').nth(i);
await product.click();
await page.waitForURL('**/products/**');
// Verify page loaded correctly
const productDetail = page.locator('h1').nth(1);
await expect(productDetail).toBeVisible();
// Go back
const backLink = page.locator('a:has-text("Back to Products")');
await backLink.click();
await page.waitForURL('**/products');
}
// Should still be functional
const productsHeading = page.locator('h1:has-text("Our Products")');
await expect(productsHeading).toBeVisible();
});
test('should handle direct URL navigation', async ({ page }) => {
// Navigate directly to a product detail page
await page.goto('/products/prod-5');
await page.waitForLoadState('domcontentloaded');
// Should load the correct product
const productName = page.locator('h1').filter({ hasText: 'Product 5' });
await expect(productName).toBeVisible();
// Navigate directly to products page
await page.goto('/products');
await page.waitForLoadState('domcontentloaded');
// Should show products listing
const productsGrid = page.locator('[class*="product-card"]');
const count = await productsGrid.count();
expect(count).toBeGreaterThan(0);
// Navigate to non-existent route should redirect to products
await page.goto('/non-existent-route');
await page.waitForURL('**/products');
// Should be on products page
const productsHeading = page.locator('h1:has-text("Our Products")');
await expect(productsHeading).toBeVisible();
});
test('should display loading states during navigation', async ({ page }) => {
// Enable slow network to see loading states
await page.route('**/*', route => {
setTimeout(() => route.continue(), 100);
});
await page.goto('/products');
// Click on a product
const product = page.locator('[class*="product-card"]').first();
await product.click();
// Should eventually load the product detail
await page.waitForURL('**/products/**', { timeout: 10000 });
const productDetail = page.locator('[class*="product-detail"]');
await expect(productDetail).toBeVisible({ timeout: 10000 });
});
});

View File

@@ -1,147 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Product Detail Page', () => {
test.beforeEach(async ({ page }) => {
// Navigate to products page first
await page.goto('/products');
await page.waitForLoadState('domcontentloaded');
});
test('should navigate to product detail when clicking a product', async ({ page }) => {
// Click on the first product
const firstProduct = page.locator('[class*="product-card"]').first();
await firstProduct.click();
// Wait for navigation
await page.waitForURL('**/products/**');
// Verify URL changed to product detail
expect(page.url()).toMatch(/\/products\/prod-\d+/);
// Verify back link is visible
const backLink = page.locator('a:has-text("Back to Products")');
await expect(backLink).toBeVisible();
});
test('should display complete product information', async ({ page }) => {
// Navigate directly to a product detail page
await page.goto('/products/prod-1');
await page.waitForLoadState('domcontentloaded');
// Check product name
const productName = page.locator('h1').filter({ hasText: /Product \d+/ });
await expect(productName).toBeVisible();
// Check product image
const productImage = page.locator('[class*="product-detail"] img');
await expect(productImage).toBeVisible();
await expect(productImage).toHaveAttribute('alt', /Product/);
// Check category
const category = page.locator('text=/Home & Garden|Electronics|Clothing|Sports|Books/');
await expect(category.first()).toBeVisible();
// Check rating
const ratingSection = page.locator('[class*="product-rating"]');
await expect(ratingSection).toBeVisible();
const stars = ratingSection.locator('[class*="stars"]');
await expect(stars).toBeVisible();
// Check price
const price = page.locator('text=/\\$\\d+\\.\\d{2}/').first();
await expect(price).toBeVisible();
// Check description
const descriptionHeading = page.locator('h2:has-text("Description")');
await expect(descriptionHeading).toBeVisible();
const description = page.locator('p').filter({ hasText: /high-quality/ });
await expect(description).toBeVisible();
// Check product information section
const productInfoHeading = page.locator('h3:has-text("Product Information")');
await expect(productInfoHeading).toBeVisible();
// Check product ID
const productId = page.locator('text=/prod-\\d+/');
await expect(productId).toBeVisible();
// Check availability - target the dd element in product info section to avoid matching the badge
const availability = page.locator('dd').filter({ hasText: /In Stock|Out of Stock/ });
await expect(availability).toBeVisible();
});
test('should show action buttons for in-stock products', async ({ page }) => {
// Navigate to products page
await page.goto('/products');
// Find an in-stock product (one without "Out of Stock" badge)
const inStockProduct = page.locator('[class*="product-card"]').filter({ hasNot: page.locator('text="Out of Stock"') }).first();
await inStockProduct.click();
// Wait for product detail page
await page.waitForURL('**/products/**');
// Check for Add to Cart button
const addToCartButton = page.locator('button:has-text("Add to Cart")');
await expect(addToCartButton).toBeVisible();
await expect(addToCartButton).toBeEnabled();
// Check for Add to Wishlist button
const addToWishlistButton = page.locator('button:has-text("Add to Wishlist")');
await expect(addToWishlistButton).toBeVisible();
await expect(addToWishlistButton).toBeEnabled();
// Test clicking Add to Cart (should show alert)
page.on('dialog', dialog => {
expect(dialog.message()).toContain('Product added to cart');
dialog.accept();
});
await addToCartButton.click();
});
test('should show disabled button for out-of-stock products', async ({ page }) => {
// Navigate to products page
await page.goto('/products');
// Find an out-of-stock product. The dataset of 50 products marks ~20% as
// out of stock, so at least one is always present.
const outOfStockProduct = page.locator('[class*="product-card"]').filter({ has: page.locator('text="Out of Stock"') }).first();
await expect(outOfStockProduct).toBeVisible();
await outOfStockProduct.click();
// Wait for product detail page
await page.waitForURL('**/products/**');
// Check for out of stock badge on detail page - use more specific selector to avoid strict mode violation
const outOfStockBadge = page.locator('.out-of-stock-badge').filter({ hasText: 'Out of Stock' });
await expect(outOfStockBadge).toBeVisible();
// Check for disabled button
const unavailableButton = page.locator('button:has-text("Currently Unavailable")');
await expect(unavailableButton).toBeVisible();
await expect(unavailableButton).toBeDisabled();
// Should not have Add to Cart or Wishlist buttons
const addToCartButton = page.locator('button:has-text("Add to Cart")');
await expect(addToCartButton).toBeHidden();
});
test('should navigate back to products list', async ({ page }) => {
// Navigate to a product detail page
await page.goto('/products/prod-1');
await page.waitForLoadState('domcontentloaded');
// Click the back link
const backLink = page.locator('a:has-text("Back to Products")');
await backLink.click();
// Should navigate back to products page
await page.waitForURL('**/products');
expect(page.url()).toContain('/products');
// Products grid should be visible
const productsGrid = page.locator('[class*="product-grid"], [class*="products"]');
await expect(productsGrid).toBeVisible();
});
});

View File

@@ -1,146 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Product Listing Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/products');
await page.waitForLoadState('domcontentloaded');
});
test('should display products grid with at least one product', async ({ page }) => {
// Check that products are displayed
const productCards = page.locator('[class*="product-card"]');
const count = await productCards.count();
expect(count).toBeGreaterThan(0);
// Check first product has required elements
const firstProduct = productCards.first();
await expect(firstProduct).toBeVisible();
// Check for product image
const productImage = firstProduct.locator('img');
await expect(productImage).toBeVisible();
// Check for product name (heading level 3)
const productName = firstProduct.locator('h3');
await expect(productName).toBeVisible();
// Check for price
const productPrice = firstProduct.locator('text=/\\$\\d+\\.\\d{2}/');
await expect(productPrice).toBeVisible();
});
test('should filter products by category', async ({ page }) => {
// Select a specific category from dropdown
const categoryDropdown = page.locator('select');
await categoryDropdown.selectOption('Electronics');
// Wait for products to load
await page.waitForFunction(() => document.querySelectorAll('[class*="product-card"]').length > 0);
// Verify all visible products are from Electronics category
const productCategories = page.locator('[class*="product-card"] p:first-of-type');
const count = await productCategories.count();
for (let i = 0; i < count; i++) {
const category = productCategories.nth(i);
await expect(category).toHaveText('Electronics');
}
});
test('should filter products by search term', async ({ page }) => {
// Enter search term
const searchInput = page.locator('input[placeholder*="Search"]');
await searchInput.fill('Product 1');
// Wait for debounce and results
await page.waitForFunction(() => document.querySelectorAll('[class*="product-card"]').length > 0);
// Check that filtered products contain the search term
const productNames = page.locator('[class*="product-card"] h3');
const count = await productNames.count();
for (let i = 0; i < count; i++) {
const name = await productNames.nth(i).textContent();
expect(name?.toLowerCase()).toContain('product 1');
}
});
test('should filter by in-stock products only', async ({ page }) => {
// Get initial product count
const resultsInfo = page.locator('text=/Showing \\d+ of \\d+ products/');
const initialText = await resultsInfo.textContent();
// Click the "In Stock Only" checkbox
const inStockCheckbox = page.locator('input[type="checkbox"]');
await inStockCheckbox.check();
// Wait for the results to update (the total count should change)
await page.waitForFunction(
(initialText) => {
const resultsElement = document.querySelector('[class*="results-info"]');
return resultsElement && resultsElement.textContent !== initialText;
},
initialText,
{ timeout: 5000 }
);
// Verify no "Out of Stock" badges are visible (web-first wait for the
// filtered DOM to settle)
const outOfStockBadges = page.locator('text="Out of Stock"');
await expect(outOfStockBadges).toHaveCount(0);
});
test('should handle pagination', async ({ page }) => {
// Check that pagination controls exist
const paginationSection = page.locator('[class*="pagination"]');
await expect(paginationSection).toBeVisible();
// Check initial state - Previous should be disabled, Next should be enabled
const prevButton = page.locator('button:has-text("Previous")');
const nextButton = page.locator('button:has-text("Next")');
const pageInfo = page.locator('text=/Page \\d+ of \\d+/');
await expect(prevButton).toBeDisabled();
await expect(nextButton).toBeEnabled();
await expect(pageInfo).toBeVisible();
// Click next button
await nextButton.click();
// Wait for new products to load
await page.waitForFunction(() => document.querySelectorAll('[class*="product-card"]').length > 0);
// Verify page changed
const newPageInfo = await pageInfo.textContent();
expect(newPageInfo).toContain('Page 2');
// Now Previous should be enabled
await expect(prevButton).toBeEnabled();
});
test('should show correct product count', async ({ page }) => {
// Check the results info text
const resultsInfo = page.locator('text=/Showing \\d+ of \\d+ products/');
await expect(resultsInfo).toBeVisible();
// Get the count from the text. The visibility assertion above guarantees
// the "Showing X of Y" text is present, so the digits are deterministic.
const text = String(await resultsInfo.textContent());
const numbers = Array.from(text.matchAll(/\d+/g), (m) => m[0]);
expect(numbers).toHaveLength(2);
const [showingText, totalText] = numbers;
const showing = parseInt(showingText);
const total = parseInt(totalText);
// Verify we're showing reasonable numbers
expect(showing).toBeGreaterThan(0);
expect(showing).toBeLessThanOrEqual(12); // Default page size
expect(total).toBeGreaterThan(0);
// Count actual product cards
const productCards = page.locator('[class*="product-card"]');
const actualCount = await productCards.count();
expect(actualCount).toBe(showing);
});
});

View File

@@ -1,46 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Shop Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should display the main header and navigation', async ({ page }) => {
// Check header title
const header = page.locator('h1').first();
await expect(header).toContainText('Nx Shop Demo');
// Check navigation link exists
const productsLink = page.locator('nav a:has-text("Products")');
await expect(productsLink).toBeVisible();
await expect(productsLink).toHaveAttribute('href', '/products');
});
test('should display footer with copyright information', async ({ page }) => {
// Check footer content
const footer = page.locator('footer');
await expect(footer).toContainText('© 2025 Nx Shop Demo');
await expect(footer).toContainText(
'Frontend (Angular) + Backend (Express) + Shared Libraries'
);
});
test('should redirect to products page by default', async ({ page }) => {
// The app should redirect from / to /products
await page.waitForURL('**/products');
expect(page.url()).toContain('/products');
// Verify we're on the products page
const productsHeading = page.locator('h1:has-text("Our Products")');
await expect(productsHeading).toBeVisible();
});
test('should have proper page title and meta', async ({ page }) => {
// Check page title
await expect(page).toHaveTitle(/shop/i);
// Check viewport is responsive
const viewport = page.viewportSize();
expect(viewport).toBeTruthy();
});
});

View File

@@ -1,25 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts"
]
}

View File

@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -1,88 +0,0 @@
{
"name": "shop",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/shop/src",
"tags": ["scope:shop"],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/shop",
"browser": "apps/shop/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/shop/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/shop/public"
}
],
"styles": ["apps/shop/src/styles.css"],
"server": "apps/shop/src/main.server.ts",
"ssr": {
"entry": "apps/shop/src/server.ts"
},
"outputMode": "server"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"dependsOn": ["api:serve"],
"continuous": true,
"executor": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "shop:build:production"
},
"development": {
"buildTarget": "shop:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "apps/shop/proxy.conf.json"
}
},
"extract-i18n": {
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "shop:build"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "shop:build",
"port": 4200,
"staticFilePath": "dist/apps/shop/browser",
"spa": true
}
}
}
}

View File

@@ -1,6 +0,0 @@
{
"/@org/api": {
"target": "http://localhost:3333",
"secure": false
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,10 +0,0 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@@ -1,22 +0,0 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZoneChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { appRoutes } from './app.routes';
import {
provideClientHydration,
withEventReplay,
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay()),
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(appRoutes),
provideHttpClient(withFetch()),
],
};

View File

@@ -1,66 +0,0 @@
.app-header {
background: #2c3e50;
color: white;
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header .container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.app-header nav {
display: flex;
gap: 24px;
}
.app-header nav a {
color: white;
text-decoration: none;
font-size: 1rem;
padding: 8px 16px;
border-radius: 4px;
transition: background 0.3s;
}
.app-header nav a:hover {
background: rgba(255, 255, 255, 0.1);
}
.app-header nav a.active {
background: #3498db;
}
.app-main {
min-height: calc(100vh - 140px);
background: #f5f5f5;
}
.app-footer {
background: #34495e;
color: white;
padding: 24px 0;
text-align: center;
}
.app-footer .container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.app-footer p {
margin: 8px 0;
font-size: 0.9rem;
}

View File

@@ -1,19 +0,0 @@
<header class="app-header">
<div class="container">
<h1>{{ title }}</h1>
<nav>
<a routerLink="/products" routerLinkActive="active">Products</a>
</nav>
</div>
</header>
<main class="app-main">
<router-outlet></router-outlet>
</main>
<footer class="app-footer">
<div class="container">
<p>© 2025 Nx Shop Demo - Showcasing Nx Monorepo Features</p>
<p>Frontend (Angular) + Backend (Express) + Shared Libraries</p>
</div>
</footer>

View File

@@ -1,12 +0,0 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'products/:id',
renderMode: RenderMode.Server,
},
{
path: '**',
renderMode: RenderMode.Server,
},
];

View File

@@ -1,25 +0,0 @@
import { Route } from '@angular/router';
export const appRoutes: Route[] = [
{
path: '',
redirectTo: 'products',
pathMatch: 'full',
},
{
path: 'products',
loadChildren: () =>
import('@org/shop/feature-products').then(m => m.featureProductsRoutes),
},
{
path: 'products',
loadChildren: () =>
import('@org/shop/feature-product-detail').then(
m => m.featureProductDetailRoutes
),
},
{
path: '**',
redirectTo: 'products',
},
];

View File

@@ -1,58 +0,0 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { App } from './app';
import { appRoutes } from './app.routes';
import { provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser';
describe('App', () => {
let component: App;
let fixture: ComponentFixture<App>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter(appRoutes)],
}).compileComponents();
fixture = TestBed.createComponent(App);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the app component', () => {
expect(component).toBeTruthy();
});
it('should render title in header', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Nx Shop Demo');
});
it('should render navigation links', () => {
const navLinks = fixture.debugElement.queryAll(By.css('nav a'));
expect(navLinks.length).toBeGreaterThan(0);
expect(navLinks[0].nativeElement.textContent).toContain('Products');
expect(navLinks[0].nativeElement.getAttribute('routerLink')).toBe(
'/products'
);
});
it('should render footer with correct copyright', () => {
const footer = fixture.nativeElement.querySelector('.app-footer');
expect(footer).toBeTruthy();
expect(footer?.textContent).toContain('© 2025 Nx Shop Demo');
expect(footer?.textContent).toContain(
'Frontend (Angular) + Backend (Express) + Shared Libraries'
);
});
it('should have router outlet for dynamic content', () => {
const routerOutlet = fixture.nativeElement.querySelector('router-outlet');
expect(routerOutlet).toBeTruthy();
});
it('should apply change detection strategy OnPush', () => {
const metadata = (App as unknown as { ɵcmp: { onPush: boolean } })['ɵcmp'];
expect(metadata.onPush).toBeTruthy();
});
});

View File

@@ -1,13 +0,0 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
protected title = 'Nx Shop Demo';
}

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>shop</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,7 +0,0 @@
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
export default bootstrap;

View File

@@ -1,5 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

View File

@@ -1,66 +0,0 @@
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/**', (req, res) => {
* // Handle API request
* });
* ```
*/
/**
* Serve static files from /browser
*/
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
})
);
/**
* Handle all other requests by rendering the Angular application.
*/
app.use('/**', (req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next()
)
.catch(next);
});
/**
* Start the server if this module is the main entry point.
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
*/
if (isMainModule(import.meta.url)) {
const port = process.env['PORT'] || 4000;
app.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
/**
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
*/
export const reqHandler = createNodeRequestHandler(app);

View File

@@ -1,38 +0,0 @@
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font-family: inherit;
}
input,
select,
textarea {
font-family: inherit;
}
img {
max-width: 100%;
height: auto;
}

View File

@@ -1,13 +0,0 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);

View File

@@ -1,25 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"src/test-setup.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -1,33 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"target": "es2022",
"moduleResolution": "bundler",
"isolatedModules": true,
"emitDecoratorMetadata": false,
"module": "preserve",
"lib": ["dom", "es2022"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -1,29 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -1,28 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import tsconfigPaths from 'vite-tsconfig-paths';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/shop',
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: 'shop',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/shop',
provider: 'v8' as const,
},
},
}));