Initial commit

This commit is contained in:
2026-07-27 18:01:07 +02:00
commit 0758b00d54
192 changed files with 45091 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,70 @@
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

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

View File

@@ -0,0 +1,162 @@
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

@@ -0,0 +1,147 @@
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

@@ -0,0 +1,146 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,25 @@
{
"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"
]
}