From 06939456f4b4779b6657ee461cc69fecf9a4796a Mon Sep 17 00:00:00 2001 From: Luk Date: Tue, 10 Jun 2025 17:01:01 +0200 Subject: [PATCH 01/25] Refactor admin API service to improve code readability and maintainability --- src/app/admin/_services/admin-api.service.ts | 44 ++++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) 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 From 24293c9fadb79bc85eddd96f3e4144bab5c936d8 Mon Sep 17 00:00:00 2001 From: Luk Date: Wed, 11 Jun 2025 09:33:10 +0200 Subject: [PATCH 02/25] Add Playwright configuration and initial test for cookie consent functionality --- .gitignore | 5 + angular.json | 14 +++ e2e/example.spec.ts | 19 ++++ e2e/tsconfig.json | 4 + package-lock.json | 241 +++++++++++++++++++++++++++++++++++++++++-- package.json | 9 +- playwright.config.ts | 85 +++++++++++++++ 7 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 e2e/example.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 playwright.config.ts 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..4133858 --- /dev/null +++ b/e2e/example.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; + +test('has title and handles cookie consent', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/AngularBlogApp/); + + 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/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..0ae8ced 100755 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "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": "playwright test --project=local" }, "private": true, "dependencies": { @@ -47,12 +49,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 +66,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'], + }, +}); From 1d42374f0da1adabb5c3318880a48642e8db06cf Mon Sep 17 00:00:00 2001 From: Luk Date: Wed, 11 Jun 2025 09:33:25 +0200 Subject: [PATCH 03/25] Add GitHub Actions workflow for E2E tests with local Supabase setup --- .github/workflows/e2e-tests.yml | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/e2e-tests.yml diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..a489acd --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,77 @@ +name: E2E Tests with Local Supabase + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + e2e-tests: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Supabase CLI + run: | + curl -fsSL https://github.com/supabase/cli/releases/download/v1.123.4/supabase_linux_amd64.tar.gz | tar -xz + sudo mv supabase /usr/local/bin/ + + - name: Start Supabase local instance + run: | + supabase start + sleep 10 + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Start Angular app + run: | + npm run start:local & + sleep 30 + # Wait for app to be ready + timeout 60 bash -c 'until curl -f http://localhost:4200; do sleep 2; done' + + - name: Run E2E tests + run: npm run e2e:local + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + - name: Stop services + if: always() + run: | + # Kill Angular dev server + pkill -f "ng serve" || true + # Stop Supabase + supabase stop || true From 4b3c98625271133f80b7e81d602d36fa7310b93f Mon Sep 17 00:00:00 2001 From: Luk Date: Wed, 11 Jun 2025 09:40:08 +0200 Subject: [PATCH 04/25] Refactor E2E test workflow to use local Supabase instance and improve service readiness checks --- .github/workflows/e2e-tests.yml | 42 +++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a489acd..9b3edf3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -10,20 +10,6 @@ jobs: e2e-tests: runs-on: ubuntu-latest - services: - postgres: - image: postgres:15 - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,26 +25,52 @@ jobs: - name: Install Supabase CLI run: | + # Remove any existing supabase binary + rm -f supabase + # Download and extract curl -fsSL https://github.com/supabase/cli/releases/download/v1.123.4/supabase_linux_amd64.tar.gz | tar -xz + # Make executable and move to system path + chmod +x supabase sudo mv supabase /usr/local/bin/ + # Verify installation + supabase --version + + - name: Start Docker + run: | + sudo systemctl start docker + sudo usermod -aG docker $USER - name: Start Supabase local instance run: | + # Start Supabase with existing config supabase start - sleep 10 + # Wait for services to be ready + sleep 15 + # Verify services are running + supabase status - name: Install Playwright browsers run: npx playwright install --with-deps - name: Start Angular app run: | + # Start Angular with local configuration npm run start:local & + # Wait for app to start sleep 30 # Wait for app to be ready timeout 60 bash -c 'until curl -f http://localhost:4200; do sleep 2; done' + env: + # Set environment variables for local Supabase + SUPABASE_URL: http://127.0.0.1:54321 + SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 - name: Run E2E tests run: npm run e2e:local + env: + # Ensure E2E tests use local Supabase + SUPABASE_URL: http://127.0.0.1:54321 + SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 - name: Upload test results uses: actions/upload-artifact@v4 From 25a52ff695e3bb5db5ca53652cda3249c1925ef4 Mon Sep 17 00:00:00 2001 From: Luk Date: Wed, 11 Jun 2025 09:43:00 +0200 Subject: [PATCH 05/25] Improve Supabase CLI installation process in E2E tests workflow --- .github/workflows/e2e-tests.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9b3edf3..286fd72 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -25,13 +25,17 @@ jobs: - name: Install Supabase CLI run: | - # Remove any existing supabase binary - rm -f supabase + # Create a temporary directory for extraction + mkdir -p /tmp/supabase-cli + cd /tmp/supabase-cli # Download and extract curl -fsSL https://github.com/supabase/cli/releases/download/v1.123.4/supabase_linux_amd64.tar.gz | tar -xz # Make executable and move to system path chmod +x supabase sudo mv supabase /usr/local/bin/ + # Clean up + cd / + rm -rf /tmp/supabase-cli # Verify installation supabase --version From fec9cdf43648d13174471fc3cf210cf4e0a002c8 Mon Sep 17 00:00:00 2001 From: Luk Date: Wed, 11 Jun 2025 09:50:38 +0200 Subject: [PATCH 06/25] Enhance E2E test workflow by cleaning npm cache and adjusting installation steps --- .github/workflows/e2e-tests.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 286fd72..0bc9e2e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -20,8 +20,16 @@ jobs: node-version: '18' cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Clean and install dependencies + run: | + # Clean npm cache and remove lock file to fix Rollup issue + npm cache clean --force + rm -f package-lock.json + rm -rf node_modules + # Fresh install + npm install + # Verify installation + npm ls @rollup/rollup-linux-x64-gnu || echo "Rollup native dependency not found, continuing..." - name: Install Supabase CLI run: | @@ -61,9 +69,9 @@ jobs: # Start Angular with local configuration npm run start:local & # Wait for app to start - sleep 30 - # Wait for app to be ready - timeout 60 bash -c 'until curl -f http://localhost:4200; do sleep 2; done' + sleep 45 + # Wait for app to be ready with longer timeout + timeout 120 bash -c 'until curl -f http://localhost:4200; do sleep 3; done' env: # Set environment variables for local Supabase SUPABASE_URL: http://127.0.0.1:54321 From e5d382c182b587f1a87ab0ed777c39a1179c8824 Mon Sep 17 00:00:00 2001 From: Luk Date: Wed, 11 Jun 2025 11:27:19 +0200 Subject: [PATCH 07/25] Add cookie consent helper and update tests to handle cookie consent functionality --- e2e/example.spec.ts | 21 +- e2e/helpers/cookie-consent.helper.ts | 16 ++ e2e/tags.spec.ts | 196 ++++++++++++++++++ package.json | 2 + .../posts-list/posts-list.component.html | 16 +- 5 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 e2e/helpers/cookie-consent.helper.ts create mode 100644 e2e/tags.spec.ts diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts index 4133858..ef49468 100644 --- a/e2e/example.spec.ts +++ b/e2e/example.spec.ts @@ -1,19 +1,12 @@ -import { expect, test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { acceptCookies } from './helpers/cookie-consent.helper'; -test('has title and handles cookie consent', async ({ page }) => { +test('has title', async ({ page }) => { await page.goto('/'); - await expect(page).toHaveTitle(/AngularBlogApp/); - - const cookieConsentDialog = page - .getByLabel('Cookie Consent') - .locator('div') - .filter({ hasText: 'Cookie Consent Consent' }) - .nth(1); - await expect(cookieConsentDialog).toBeVisible(); + // Handle cookie consent using helper + await acceptCookies(page); - const allowAllButton = page.getByRole('button', { name: 'Allow All' }); - await allowAllButton.click(); - - await expect(cookieConsentDialog).not.toBeVisible(); + // 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/tags.spec.ts b/e2e/tags.spec.ts new file mode 100644 index 0000000..9a981d1 --- /dev/null +++ b/e2e/tags.spec.ts @@ -0,0 +1,196 @@ +import { test, expect } from '@playwright/test'; +import { acceptCookies } from './helpers/cookie-consent.helper'; + +test.describe('Tags Display and API', () => { + test('should display tags and verify API call', async ({ page }) => { + // Set up network monitoring before navigation + const apiRequests: Array<{ url: string; method: string; headers: Record }> = []; + + page.on('request', (request) => { + const url = request.url(); + if (url.includes('/rest/v1/tags')) { + apiRequests.push({ + url, + method: request.method(), + headers: request.headers() + }); + } + }); + + // Navigate to homepage + await page.goto('/'); + + // Handle cookie consent + await acceptCookies(page); + + // Wait for tags container to load + await page.waitForSelector('[data-testid="tags-container"]', { timeout: 15000 }); + + // Verify tags container is visible + const tagsContainer = page.locator('[data-testid="tags-container"]'); + await expect(tagsContainer).toBeVisible(); + + // Verify scroll container + const scrollContainer = page.locator('[data-testid="tags-scroll-container"]'); + await expect(scrollContainer).toBeVisible(); + + // Wait for at least one tag to appear + await page.waitForSelector('[data-testid="tag-item"]', { timeout: 10000 }); + + // Verify specific tags are displayed with correct structure + const expectedTags = [ + { name: 'Angular', color: '#DD0031', icon: 'angular.svg' }, + { name: 'TypeScript', color: '#007ACC', icon: 'typescript.svg' }, + { name: 'JavaScript', color: '#F7DF1E', icon: 'javascript.svg' }, + { name: 'Firebase', color: '#FFCA28', icon: 'firebase.svg' }, + { name: 'Node.js', color: '#339933', icon: 'nodejs.svg' } + ]; + + for (const tag of expectedTags) { + // Find tag by data attribute + const tagItem = page.locator(`[data-testid="tag-item"][data-tag-name="${tag.name}"]`); + await expect(tagItem).toBeVisible(); + + // Check tag name + const tagName = tagItem.locator('[data-testid="tag-name"]'); + await expect(tagName).toHaveText(tag.name); + + // Check tag icon + const tagIcon = tagItem.locator('[data-testid="tag-icon"]'); + await expect(tagIcon).toBeVisible(); + await expect(tagIcon).toHaveAttribute('alt', tag.name); + await expect(tagIcon).toHaveAttribute('src', expect.stringContaining(tag.icon)); + + // Check background color (the parent div with inline style) + const tagButton = tagItem.locator('[data-testid="tag-button"]'); + await expect(tagButton).toHaveAttribute('style', expect.stringContaining(tag.color)); + } + + // Verify navigation buttons (desktop only) + const leftButton = page.locator('[data-testid="scroll-left-button"]'); + const rightButton = page.locator('[data-testid="scroll-right-button"]'); + + // Check if buttons exist (they might be hidden on mobile) + await expect(leftButton).toBeAttached(); + await expect(rightButton).toBeAttached(); + + + // Test tag hover effects (desktop) + const viewport = page.viewportSize(); + if (viewport && viewport.width >= 768) { + const firstTagButton = page.locator('[data-testid="tag-button"]').first(); + await firstTagButton.hover(); + + // Should have hover classes + await expect(firstTagButton).toHaveClass(/hover:scale-110/); + } + + // Verify API call was made + await page.waitForTimeout(2000); // Give time for API calls to complete + expect(apiRequests.length).toBeGreaterThan(0); + + const tagsApiCall = apiRequests[0]; + expect(tagsApiCall.url).toContain('/rest/v1/tags'); + expect(tagsApiCall.method).toBe('GET'); + + // Verify headers + expect(tagsApiCall.headers['apikey']).toBeDefined(); + expect(tagsApiCall.headers['authorization']).toContain('Bearer'); + expect(tagsApiCall.headers['accept']).toBe('application/json'); + + console.log('Tags API call verified:', tagsApiCall); + }); + + test('should handle tag interactions and scrolling', async ({ page }) => { + await page.goto('/'); + await acceptCookies(page); + + // Wait for tags to load + await page.waitForSelector('[data-testid="tags-container"]', { timeout: 15000 }); + await page.waitForSelector('[data-testid="tag-item"]', { timeout: 10000 }); + + // Test clicking on a tag (verify it's clickable) + const angularTag = page.locator('[data-testid="tag-item"][data-tag-name="Angular"]'); + await expect(angularTag).toBeVisible(); + + // Verify cursor pointer styling on tag button + const tagButton = angularTag.locator('[data-testid="tag-button"]'); + await expect(tagButton).toHaveClass(/cursor-pointer/); + + // Test scroll functionality + const scrollContainer = page.locator('[data-testid="tags-scroll-container"]'); + + // Get initial scroll position + const initialScrollLeft = await scrollContainer.evaluate(el => el.scrollLeft); + + // Test desktop scroll buttons if visible + const viewport = page.viewportSize(); + if (viewport && viewport.width >= 768) { + const rightButton = page.locator('[data-testid="scroll-right-button"]'); + if (await rightButton.isVisible()) { + await rightButton.click(); + + // Verify scroll position changed + await page.waitForTimeout(500); // Wait for scroll animation + const newScrollLeft = await scrollContainer.evaluate(el => el.scrollLeft); + expect(newScrollLeft).toBeGreaterThan(initialScrollLeft); + } + } else { + // Test mobile scroll + await scrollContainer.evaluate(el => el.scrollLeft += 100); + + // Verify scroll position changed + const newScrollLeft = await scrollContainer.evaluate(el => el.scrollLeft); + expect(newScrollLeft).toBeGreaterThan(initialScrollLeft); + } + }); + + test('should display all expected tags from seed data', async ({ page }) => { + await page.goto('/'); + await acceptCookies(page); + + await page.waitForSelector('[data-testid="tags-container"]', { timeout: 15000 }); + await page.waitForSelector('[data-testid="tag-item"]', { timeout: 10000 }); + + // All tags from seed data + const allExpectedTags = [ + 'Angular', 'TypeScript', 'JavaScript', 'Firebase', 'Firestore', + 'Node.js', 'Cloud Computing', 'SSG/SSR', 'Web Development', + 'Performance', 'Security', 'Deployment', 'Testing', 'Best Practices', + 'Tutorials', 'HTML', 'CSS' + ]; + + // Count visible tags using data-testid + const tagElements = page.locator('[data-testid="tag-item"]'); + const tagCount = await tagElements.count(); + + console.log(`Found ${tagCount} tags on the page`); + expect(tagCount).toBeGreaterThanOrEqual(5); // At least some tags should be visible + + // Check for specific important tags + const importantTags = ['Angular', 'TypeScript', 'JavaScript']; + for (const tagName of importantTags) { + const tagElement = page.locator(`[data-testid="tag-item"][data-tag-name="${tagName}"]`); + await expect(tagElement).toBeVisible(); + + const tagNameElement = tagElement.locator('[data-testid="tag-name"]'); + await expect(tagNameElement).toHaveText(tagName); + } + }); + + test('should handle empty state gracefully', async ({ page }) => { + // This test could simulate no tags loaded scenario + await page.goto('/'); + await acceptCookies(page); + + // Wait for tags container + await page.waitForSelector('[data-testid="tags-container"]', { timeout: 15000 }); + + // The container should still be visible even if no tags are loaded + const tagsContainer = page.locator('[data-testid="tags-container"]'); + await expect(tagsContainer).toBeVisible(); + + const scrollContainer = page.locator('[data-testid="tags-scroll-container"]'); + await expect(scrollContainer).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 0ae8ced..ef4d484 100755 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "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", 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 @@
-
+
-