diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..7a1ebfc --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,49 @@ +name: E2E Tests with Local Supabase + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + e2e: + if: "!contains(github.event.head_commit.message, 'E2E')" + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: npm } + - run: npm ci + - run: | + node - <<'NODE' + const v = require('./package.json').devDependencies.rollup ?? 'latest'; + console.log('ℹ Installing native Rollup helper for', v); + NODE + - run: npm install --no-save @rollup/rollup-linux-x64-gnu@$(node -p "require('./package.json').devDependencies.rollup || '4'") + + - uses: supabase/setup-cli@v1 + with: { version: 2.24.3 } + + - name: Start Supabase + env: { SUPABASE_TELEMETRY_DISABLED: "1" } + run: supabase start & + + - name: Wait for Supabase (≤180 s) + run: npx --yes wait-on tcp:127.0.0.1:54321 tcp:127.0.0.1:54322 --timeout 180000 + + - run: echo "SUPABASE_URL=http://127.0.0.1:54321" >> $GITHUB_ENV + - run: | + echo "SUPABASE_ANON_KEY=$(supabase status -o env | grep SUPABASE_ANON_KEY | cut -d= -f2)" >> $GITHUB_ENV + + - run: npx playwright install --with-deps + - run: npm run e2e:local + env: { CI: "true" } + + - uses: actions/upload-artifact@v4 + if: always() + with: { name: playwright-report, path: playwright-report } diff --git a/.gitignore b/.gitignore index 3414f20..3197472 100755 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ Thumbs.db .runtimeconfig.json adminSdkConf.json + +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/angular.json b/angular.json index 59fcc52..1ad5041 100755 --- a/angular.json +++ b/angular.json @@ -127,6 +127,20 @@ ], "scripts": [] } + }, + "e2e": { + "builder": "playwright-ng-schematics:playwright", + "options": { + "devServerTarget": "angularblogapp:serve" + }, + "configurations": { + "production": { + "devServerTarget": "angularblogapp:serve:production" + }, + "local": { + "devServerTarget": "angularblogapp:serve:local" + } + } } } } diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts new file mode 100644 index 0000000..ef49468 --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; +import { acceptCookies } from './helpers/cookie-consent.helper'; + +test('has title', async ({ page }) => { + await page.goto('/'); + + // Handle cookie consent using helper + await acceptCookies(page); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/AngularBlogApp/); +}); diff --git a/e2e/helpers/cookie-consent.helper.ts b/e2e/helpers/cookie-consent.helper.ts new file mode 100644 index 0000000..8243ff0 --- /dev/null +++ b/e2e/helpers/cookie-consent.helper.ts @@ -0,0 +1,16 @@ +import { Page, expect } from '@playwright/test'; + +export async function acceptCookies(page: Page): Promise { + const cookieConsentDialog = page + .getByLabel('Cookie Consent') + .locator('div') + .filter({ hasText: 'Cookie Consent Consent' }) + .nth(1); + + await expect(cookieConsentDialog).toBeVisible(); + + const allowAllButton = page.getByRole('button', { name: 'Allow All' }); + await allowAllButton.click(); + + await expect(cookieConsentDialog).not.toBeVisible(); +} diff --git a/e2e/helpers/debug.helper.ts b/e2e/helpers/debug.helper.ts new file mode 100644 index 0000000..a9c86f8 --- /dev/null +++ b/e2e/helpers/debug.helper.ts @@ -0,0 +1,128 @@ +import { Page } from '@playwright/test'; + +export async function debugPageState(page: Page, testName: string) { + console.log(`=== DEBUG INFO FOR: ${testName} ===`); + + // 1. Check current URL + console.log('Current URL:', page.url()); + + // 2. Check if Angular app is loaded + const angularLoaded = await page.evaluate(() => { + return !!(window as any).ng; + }); + console.log('Angular loaded:', angularLoaded); + + // 3. Check network requests + const allRequests: string[] = []; + page.on('request', (request) => { + allRequests.push(`${request.method()} ${request.url()}`); + }); + + // 4. Check for JavaScript errors + const jsErrors: string[] = []; + page.on('pageerror', (error) => { + jsErrors.push(error.message); + }); + + // 5. Check console logs + const consoleLogs: string[] = []; + page.on('console', (msg) => { + consoleLogs.push(`${msg.type()}: ${msg.text()}`); + }); + + // 6. Check if tags container exists + const tagsContainer = await page.locator('[data-testid="tags-container"]').count(); + console.log('Tags container count:', tagsContainer); + + // 7. Check if tags list exists + const tagsList = await page.locator('[data-testid="tags-list"]').count(); + console.log('Tags list count:', tagsList); + + // 8. Check if any tag items exist + const tagItems = await page.locator('[data-testid="tag-item"]').count(); + console.log('Tag items count:', tagItems); + + // 9. Check the HTML content of tags container + if (tagsContainer > 0) { + const tagsContainerHTML = await page.locator('[data-testid="tags-container"]').innerHTML(); + console.log('Tags container HTML:', tagsContainerHTML); + } + + // 10. Check for @for loop elements (Angular control flow) + const ngForElements = await page.locator('[ng-for]').count(); + console.log('ng-for elements count:', ngForElements); + + // 11. Check Angular component state + const componentState = await page.evaluate(() => { + // Try to access Angular component data + const appRoot = document.querySelector('app-root'); + if (appRoot) { + return { + hasAppRoot: true, + innerHTML: appRoot.innerHTML.substring(0, 500) + '...' + }; + } + return { hasAppRoot: false }; + }); + console.log('Component state:', componentState); + + // 12. Check API calls + console.log('Recent network requests:', allRequests.slice(-10)); + console.log('JavaScript errors:', jsErrors); + console.log('Recent console logs:', consoleLogs.slice(-10)); + + // 13. Check if Supabase client is available + const supabaseAvailable = await page.evaluate(() => { + return typeof (window as any).supabase !== 'undefined'; + }); + console.log('Supabase client available:', supabaseAvailable); + + // 14. Check environment variables + const envCheck = await page.evaluate(() => { + return { + hasSupabaseUrl: typeof process !== 'undefined' && !!process.env?.['SUPABASE_URL'], + hasSupabaseKey: typeof process !== 'undefined' && !!process.env?.['SUPABASE_ANON_KEY'] + }; + }); + console.log('Environment check:', envCheck); + + console.log('=== END DEBUG INFO ===\n'); +} + +export async function waitForAngularToLoad(page: Page, timeout = 10000) { + console.log('Waiting for Angular to load...'); + + try { + // Wait for Angular to be ready + await page.waitForFunction(() => { + return !!(window as any).ng && document.querySelector('app-root'); + }, { timeout }); + + console.log('Angular loaded successfully'); + return true; + } catch (error) { + console.log('Angular failed to load within timeout:', error); + return false; + } +} + +export async function waitForApiCall(page: Page, urlPattern: string, timeout = 15000) { + console.log(`Waiting for API call matching: ${urlPattern}`); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`API call to ${urlPattern} not detected within ${timeout}ms`)); + }, timeout); + + const requestHandler = (request: any) => { + if (request.url().includes(urlPattern)) { + console.log(`API call detected: ${request.url()}`); + clearTimeout(timer); + page.off('request', requestHandler); + resolve(request); + } + }; + + page.on('request', requestHandler); + }); +} diff --git a/e2e/tags.spec.ts b/e2e/tags.spec.ts new file mode 100644 index 0000000..3593065 --- /dev/null +++ b/e2e/tags.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; +import { acceptCookies } from './helpers/cookie-consent.helper'; +import { waitForAngularToLoad, waitForApiCall } from './helpers/debug.helper'; + +test.describe('Tags Display and API', () => { + test('should display tags and verify API call', async ({ page }) => { + const apiRequests: Array<{ + url: string; + method: string; + headers: Record; + status?: number; + }> = []; + + page.on('request', (request) => { + const url = request.url(); + if ( + url.includes('/rest/v1/tags') || + url.includes('supabase') || + url.includes('tag') + ) { + apiRequests.push({ + url, + method: request.method(), + headers: request.headers(), + }); + } + }); + + page.on('response', (response) => { + const url = response.url(); + if (url.includes('/rest/v1/tags')) { + const existingRequest = apiRequests.find((req) => req.url === url); + if (existingRequest) { + existingRequest.status = response.status(); + } + } + }); + + await page.goto('/', { waitUntil: 'networkidle' }); + await acceptCookies(page); + await waitForAngularToLoad(page, 500); + await page.waitForSelector('[data-testid="tags-container"]', { + timeout: 500, + }); + + try { + await waitForApiCall(page, '/rest/v1/tags', 300); + } catch (error) { + // Continue test even if API call detection fails + } + + await page.waitForTimeout(1000); + await page.waitForSelector('[data-testid="tag-item"]', { timeout: 500 }); + + const tagsContainer = page.locator('[data-testid="tags-container"]'); + await expect(tagsContainer).toBeVisible(); + + const expectedTags = [ + { name: 'Angular', color: '#DD0031', icon: 'angular.svg' }, + { name: 'TypeScript', color: '#007ACC', icon: 'typescript.svg' }, + { name: 'JavaScript', color: '#F7DF1E', icon: 'javascript.svg' }, + ]; + + for (const tag of expectedTags) { + const tagItem = page.locator( + `[data-testid="tag-item"][data-tag-name="${tag.name}"]`, + ); + await expect(tagItem).toBeVisible(); + + const tagName = tagItem.locator('[data-testid="tag-name"]'); + await expect(tagName).toHaveText(tag.name); + } + + expect(apiRequests.length).toBeGreaterThan(0); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..5197ce2 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index ffdf6c0..9b33a53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,12 +37,13 @@ "@angular-devkit/build-angular": "^19.2.1", "@angular/cli": "^19.2.1", "@angular/compiler-cli": "^19.2.1", + "@playwright/test": "^1.53.0", "@snaplet/copycat": "^6.0.0", "@snaplet/seed": "^0.98.0", "@types/compression": "^1.7.5", "@types/express": "^4.17.17", "@types/jasmine": "~5.1.0", - "@types/node": "^18.18.0", + "@types/node": "^18.19.111", "@types/pg": "^8.15.2", "autoprefixer": "^10.4.19", "daisyui": "^4.12.10", @@ -53,9 +54,11 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "pg": "^8.16.0", + "playwright-ng-schematics": "^2.0.3", "postcss": "^8.4.38", "prettier": "3.4.2", "tailwindcss": "^3.4.4", + "ts-node": "^10.9.2", "tsx": "^4.19.4", "typescript": "~5.7.3", "webpack-bundle-analyzer": "^4.10.2", @@ -2439,6 +2442,30 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -3802,6 +3829,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz", + "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.28", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", @@ -4814,6 +4857,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -5030,9 +5101,9 @@ } }, "node_modules/@types/node": { - "version": "18.19.80", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.80.tgz", - "integrity": "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ==", + "version": "18.19.111", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.111.tgz", + "integrity": "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw==", "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -5462,7 +5533,7 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5475,7 +5546,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -7080,6 +7151,13 @@ } } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -7492,6 +7570,16 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/digest-fetch": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", @@ -10992,6 +11080,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, "node_modules/make-fetch-happen": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", @@ -15593,6 +15688,70 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz", + "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz", + "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright-ng-schematics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/playwright-ng-schematics/-/playwright-ng-schematics-2.0.3.tgz", + "integrity": "sha512-2mTQFmhiVnbLx3T5OSFp+xUo9CGgwTMtaUA2w697PQIH0c+93bVeyiSnR2UNuu0A7AxCpersA8n1RR5SO0IFNg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@angular-devkit/architect": ">= 0.1900.0 < 0.2000.0", + "@angular-devkit/core": "^19.0.0", + "@angular-devkit/schematics": "^19.0.0" + }, + "peerDependencies": { + "@angular-devkit/architect": ">= 0.1900.0 < 0.2000.0", + "@angular-devkit/core": "^19.0.0", + "@angular-devkit/schematics": "^19.0.0" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -18387,6 +18546,57 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -18465,7 +18675,7 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -18713,6 +18923,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/valid-url": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", @@ -19709,6 +19926,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", diff --git a/package.json b/package.json index 2207252..ae3d396 100755 --- a/package.json +++ b/package.json @@ -11,10 +11,14 @@ "build:stats": "ng build --stats-json", "analyze": "webpack-bundle-analyzer dist/angular-blog-app/stats.json", "start:local": "ng serve --configuration=local", + "start:local:docker": "open -a Docker", + "start:local:backend": "npx supabase start", "schema:pull": "find supabase/migrations -name '*_remote_schema.sql' -delete && supabase db pull --db-url $PG_EXPORT_URL", "db:createSeed": "scripts/create-seed.sh", "db:seed": "npx @snaplet/seed init", - "users:passwords": "scripts/set-passwords.sh" + "users:passwords": "scripts/set-passwords.sh", + "e2e": "ng e2e", + "e2e:local": "ng e2e -c local" }, "private": true, "dependencies": { @@ -47,12 +51,13 @@ "@angular-devkit/build-angular": "^19.2.1", "@angular/cli": "^19.2.1", "@angular/compiler-cli": "^19.2.1", + "@playwright/test": "^1.53.0", "@snaplet/copycat": "^6.0.0", "@snaplet/seed": "^0.98.0", "@types/compression": "^1.7.5", "@types/express": "^4.17.17", "@types/jasmine": "~5.1.0", - "@types/node": "^18.18.0", + "@types/node": "^18.19.111", "@types/pg": "^8.15.2", "autoprefixer": "^10.4.19", "daisyui": "^4.12.10", @@ -63,9 +68,11 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "pg": "^8.16.0", + "playwright-ng-schematics": "^2.0.3", "postcss": "^8.4.38", "prettier": "3.4.2", "tailwindcss": "^3.4.4", + "ts-node": "^10.9.2", "tsx": "^4.19.4", "typescript": "~5.7.3", "webpack-bundle-analyzer": "^4.10.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..06d19b7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,85 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env['CI'], + /* Retry on CI only */ + retries: process.env['CI'] ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env['CI'] ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env['PLAYWRIGHT_TEST_BASE_URL'] ?? 'http://localhost:4200', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + { + name: 'local', + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://localhost:4200', + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Configure local web server for testing */ + webServer: process.env['CI'] ? undefined : { + command: 'npm run start:local', + url: 'http://localhost:4200', + reuseExistingServer: !process.env['CI'], + }, +}); diff --git a/public/06-04-2025.webp b/public/06-04-2025.webp deleted file mode 100644 index b894719..0000000 Binary files a/public/06-04-2025.webp and /dev/null differ diff --git a/public/09-07-2024-big.webp b/public/09-07-2024-big.webp deleted file mode 100644 index db1b782..0000000 Binary files a/public/09-07-2024-big.webp and /dev/null differ diff --git a/public/09-07-2024-small.webp b/public/09-07-2024-small.webp deleted file mode 100644 index db1b782..0000000 Binary files a/public/09-07-2024-small.webp and /dev/null differ diff --git a/public/11-06-2025-post.webp b/public/11-06-2025-post.webp new file mode 100644 index 0000000..e49c252 Binary files /dev/null and b/public/11-06-2025-post.webp differ diff --git a/public/13-08-2024-big.webp b/public/13-08-2024-big.webp deleted file mode 100644 index bc74f19..0000000 Binary files a/public/13-08-2024-big.webp and /dev/null differ diff --git a/public/13-08-2024-small.webp b/public/13-08-2024-small.webp deleted file mode 100644 index 3be30ed..0000000 Binary files a/public/13-08-2024-small.webp and /dev/null differ diff --git a/public/20-10-2024-big.webp b/public/20-10-2024-big.webp deleted file mode 100644 index 2b1a65b..0000000 Binary files a/public/20-10-2024-big.webp and /dev/null differ diff --git a/public/20-10-2024-small-post.webp b/public/20-10-2024-small-post.webp deleted file mode 100644 index a2df663..0000000 Binary files a/public/20-10-2024-small-post.webp and /dev/null differ diff --git a/public/20-10-2024-small.webp b/public/20-10-2024-small.webp deleted file mode 100644 index a2df663..0000000 Binary files a/public/20-10-2024-small.webp and /dev/null differ diff --git a/public/30-06-2024-big.webp b/public/30-06-2024-big.webp deleted file mode 100644 index c82ab65..0000000 Binary files a/public/30-06-2024-big.webp and /dev/null differ diff --git a/public/30-06-2024-small.webp b/public/30-06-2024-small.webp deleted file mode 100644 index b79232f..0000000 Binary files a/public/30-06-2024-small.webp and /dev/null differ diff --git a/src/app/admin/_services/admin-api.service.ts b/src/app/admin/_services/admin-api.service.ts index 9d1866f..8da5bac 100755 --- a/src/app/admin/_services/admin-api.service.ts +++ b/src/app/admin/_services/admin-api.service.ts @@ -3,7 +3,7 @@ import { map, Observable } from 'rxjs'; import { Post, PostInsert, PostUpdate, Tag } from '../../supabase-types'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { SupabaseService } from '../../services/supabase.service'; -import { environment } from '../../../environments/environment.local'; +import { environment } from '../../../environments/environment'; @Injectable() export class AdminApiService { @@ -16,11 +16,12 @@ export class AdminApiService { const { tags, ...postData } = post; try { - const { data: insertedPost, error: postError } = await this.supabaseService.getClient - .from('posts') - .insert({ ...postData }) - .select('id') - .single(); + const { data: insertedPost, error: postError } = + await this.supabaseService.getClient + .from('posts') + .insert({ ...postData }) + .select('id') + .single(); if (postError) { console.error('Error inserting post:', postError); @@ -28,9 +29,9 @@ export class AdminApiService { } if (tags && tags.length > 0 && insertedPost) { - const postTagInserts = tags.map(tag => ({ + const postTagInserts = tags.map((tag) => ({ post_id: insertedPost.id, - tag_id: tag.id + tag_id: tag.id, })); const { error: tagError } = await this.supabaseService.getClient @@ -75,7 +76,10 @@ export class AdminApiService { .pipe(map((results) => results[0] ?? null)); } - async updatePost(id: string, post: PostUpdate & { tags?: Tag[] }): Promise { + async updatePost( + id: string, + post: PostUpdate & { tags?: Tag[] }, + ): Promise { const { tags, ...postData } = post; try { @@ -93,21 +97,25 @@ export class AdminApiService { // Handle tags if provided if (tags !== undefined) { // Get existing tags for comparison - const { data: existingPostTags, error: fetchError } = await this.supabaseService.getClient - .from('post_tags') - .select('tag_id') - .eq('post_id', id); + const { data: existingPostTags, error: fetchError } = + await this.supabaseService.getClient + .from('post_tags') + .select('tag_id') + .eq('post_id', id); if (fetchError) { console.error('Error fetching existing post tags:', fetchError); throw fetchError; } - const existingTagIds = (existingPostTags || []).map(pt => pt.tag_id).sort(); - const newTagIds = tags.map(tag => tag.id).sort(); + const existingTagIds = (existingPostTags || []) + .map((pt) => pt.tag_id) + .sort(); + const newTagIds = tags.map((tag) => tag.id).sort(); // Check if tags have actually changed using JSON comparison for better accuracy - const tagsChanged = JSON.stringify(existingTagIds) !== JSON.stringify(newTagIds); + const tagsChanged = + JSON.stringify(existingTagIds) !== JSON.stringify(newTagIds); if (tagsChanged) { console.log('Tags changed, updating...'); @@ -125,9 +133,9 @@ export class AdminApiService { // Insert new post-tag relationships if tags exist if (tags.length > 0) { - const postTagInserts = tags.map(tag => ({ + const postTagInserts = tags.map((tag) => ({ post_id: id, - tag_id: tag.id + tag_id: tag.id, })); const { error: insertError } = await this.supabaseService.getClient diff --git a/src/app/reader/_components/main-page/posts-list/posts-list.component.html b/src/app/reader/_components/main-page/posts-list/posts-list.component.html index a79b20a..e7caa4a 100755 --- a/src/app/reader/_components/main-page/posts-list/posts-list.component.html +++ b/src/app/reader/_components/main-page/posts-list/posts-list.component.html @@ -1,15 +1,17 @@ -
+
-
+
@for (tag of tags(); track tag.id) { -
+
{{ tag.name }}
@@ -28,19 +32,20 @@
-
+
-