mirror of
				https://github.com/kevinveenbirkenbach/homepage.veen.world.git
				synced 2025-11-04 09:27:58 +00:00 
			
		
		
		
	Added cypress tests
This commit is contained in:
		
							
								
								
									
										82
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,82 @@
 | 
				
			|||||||
 | 
					# Load environment variables from .env
 | 
				
			||||||
 | 
					ifneq (,$(wildcard .env))
 | 
				
			||||||
 | 
					    include .env
 | 
				
			||||||
 | 
					    # Export variables defined in .env
 | 
				
			||||||
 | 
					    export $(shell sed 's/=.*//' .env)
 | 
				
			||||||
 | 
					endif
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default port (can be overridden with PORT env var)
 | 
				
			||||||
 | 
					PORT ?= 5000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default port (can be overridden with PORT env var)
 | 
				
			||||||
 | 
					.PHONY: build
 | 
				
			||||||
 | 
					build:
 | 
				
			||||||
 | 
						# Build the Docker image.
 | 
				
			||||||
 | 
						docker build -t application-portfolio .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: up
 | 
				
			||||||
 | 
					up:
 | 
				
			||||||
 | 
						# Start the application using docker-compose with build.
 | 
				
			||||||
 | 
						docker-compose up -d --build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: down
 | 
				
			||||||
 | 
					down:
 | 
				
			||||||
 | 
						# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
 | 
				
			||||||
 | 
						- docker stop portfolio || true
 | 
				
			||||||
 | 
						- docker rm portfolio || true
 | 
				
			||||||
 | 
						- docker-compose down
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: run-dev
 | 
				
			||||||
 | 
					run-dev:
 | 
				
			||||||
 | 
						# Run the container in development mode (hot-reload).
 | 
				
			||||||
 | 
						docker run -d \
 | 
				
			||||||
 | 
							-p $(PORT):$(PORT) \
 | 
				
			||||||
 | 
							--name portfolio \
 | 
				
			||||||
 | 
							-v $(PWD)/app/:/app \
 | 
				
			||||||
 | 
							-e FLASK_APP=app.py \
 | 
				
			||||||
 | 
							-e FLASK_ENV=development \
 | 
				
			||||||
 | 
							application-portfolio
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: run-prod
 | 
				
			||||||
 | 
					run-prod:
 | 
				
			||||||
 | 
						# Run the container in production mode.
 | 
				
			||||||
 | 
						docker run -d \
 | 
				
			||||||
 | 
							-p $(PORT):$(PORT) \
 | 
				
			||||||
 | 
							--name portfolio \
 | 
				
			||||||
 | 
							application-portfolio
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: logs
 | 
				
			||||||
 | 
					logs:
 | 
				
			||||||
 | 
						# Display the logs of the 'portfolio' container.
 | 
				
			||||||
 | 
						docker logs -f portfolio
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: dev
 | 
				
			||||||
 | 
					dev:
 | 
				
			||||||
 | 
						# Start the application in development mode using docker-compose.
 | 
				
			||||||
 | 
						FLASK_ENV=development docker-compose up -d
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: prod
 | 
				
			||||||
 | 
					prod:
 | 
				
			||||||
 | 
						# Start the application in production mode using docker-compose (with build).
 | 
				
			||||||
 | 
						docker-compose up -d --build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: cleanup
 | 
				
			||||||
 | 
					cleanup:
 | 
				
			||||||
 | 
						# Remove all stopped Docker containers to reclaim space.
 | 
				
			||||||
 | 
						docker container prune -f
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: delete
 | 
				
			||||||
 | 
					delete:
 | 
				
			||||||
 | 
						# Force remove the 'portfolio' container if it exists.
 | 
				
			||||||
 | 
						- docker rm -f portfolio
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: browse
 | 
				
			||||||
 | 
					browse:
 | 
				
			||||||
 | 
						# Open the application in the browser at http://localhost:$(PORT)
 | 
				
			||||||
 | 
						chromium http://localhost:$(PORT)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cypress tests\ nCYPRESS_DIR := app
 | 
				
			||||||
 | 
					.PHONY: test
 | 
				
			||||||
 | 
					test: down prod
 | 
				
			||||||
 | 
						# Run end-to-end tests with Cypress.
 | 
				
			||||||
 | 
						cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"
 | 
				
			||||||
							
								
								
									
										2
									
								
								app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					node_modules/
 | 
				
			||||||
 | 
					package-lock.json
 | 
				
			||||||
							
								
								
									
										19
									
								
								app/cypress.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/cypress.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					// cypress.config.js
 | 
				
			||||||
 | 
					const { defineConfig } = require('cypress');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = defineConfig({
 | 
				
			||||||
 | 
					  e2e: {
 | 
				
			||||||
 | 
					    // your app under test must already be running on this port
 | 
				
			||||||
 | 
					    baseUrl: `http://localhost:${process.env.PORT || 5001}`,
 | 
				
			||||||
 | 
					    defaultCommandTimeout: 60000,
 | 
				
			||||||
 | 
					    pageLoadTimeout: 60000,
 | 
				
			||||||
 | 
					    requestTimeout: 1500,
 | 
				
			||||||
 | 
					    responseTimeout: 15000,
 | 
				
			||||||
 | 
					    specPattern: 'cypress/e2e/**/*.spec.js',
 | 
				
			||||||
 | 
					    supportFile: false,
 | 
				
			||||||
 | 
					    setupNodeEvents(on, config) {
 | 
				
			||||||
 | 
					      // here you could hook into events, but we don’t need anything special
 | 
				
			||||||
 | 
					      return config;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										90
									
								
								app/cypress/e2e/container.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								app/cypress/e2e/container.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/container.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Custom Scroll & Container Resizing', () => {
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    // Assumes your app is running at baseUrl, and container.js is loaded on “/”
 | 
				
			||||||
 | 
					    cy.visit('/');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('on load, the scroll-container gets a positive height and proper overflow', () => {
 | 
				
			||||||
 | 
					    // wait for our JS to run
 | 
				
			||||||
 | 
					    cy.window().should('have.property', 'adjustScrollContainerHeight');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Grab the inline style of .scroll-container
 | 
				
			||||||
 | 
					    cy.get('.scroll-container')
 | 
				
			||||||
 | 
					      .should('have.attr', 'style')
 | 
				
			||||||
 | 
					      .then(style => {
 | 
				
			||||||
 | 
					        // height:<number>px must be present
 | 
				
			||||||
 | 
					        const m = style.match(/height:\s*(\d+(?:\.\d+)?)px/);
 | 
				
			||||||
 | 
					        expect(m, 'height set').to.not.be.null;
 | 
				
			||||||
 | 
					        expect(parseFloat(m[1]), 'height > 0').to.be.greaterThan(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // overflow shorthand should include both hidden & auto (order-insensitive)
 | 
				
			||||||
 | 
					        expect(style).to.include('overflow:');
 | 
				
			||||||
 | 
					        expect(style).to.match(/overflow:\s*(hidden\s+auto|auto\s+hidden)/);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('on window resize, scroll-container height updates', () => {
 | 
				
			||||||
 | 
					    // record original height
 | 
				
			||||||
 | 
					    cy.get('.scroll-container')
 | 
				
			||||||
 | 
					      .invoke('css', 'height')
 | 
				
			||||||
 | 
					      .then(orig => {
 | 
				
			||||||
 | 
					        // resize to a smaller viewport
 | 
				
			||||||
 | 
					        cy.viewport(320, 480);
 | 
				
			||||||
 | 
					        cy.wait(100); // allow resize handler to fire
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cy.get('.scroll-container')
 | 
				
			||||||
 | 
					          .invoke('css', 'height')
 | 
				
			||||||
 | 
					          .then(newH => {
 | 
				
			||||||
 | 
					            expect(parseFloat(newH), 'height changed on resize').to.not.equal(parseFloat(orig));
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context('custom scrollbar thumb', () => {
 | 
				
			||||||
 | 
					    beforeEach(() => {
 | 
				
			||||||
 | 
					      // inject tall content to force scrolling
 | 
				
			||||||
 | 
					      cy.get('.scroll-container').then($sc => {
 | 
				
			||||||
 | 
					        $sc[0].innerHTML = '<div style="height:2000px">long</div>';
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      // re-run scrollbar setup
 | 
				
			||||||
 | 
					      cy.window().invoke('updateCustomScrollbar');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('shows a thumb with reasonable size & position', () => {
 | 
				
			||||||
 | 
					      cy.get('#custom-scrollbar').should('have.css', 'opacity', '1');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      cy.get('#scroll-thumb')
 | 
				
			||||||
 | 
					        .should('have.css', 'height')
 | 
				
			||||||
 | 
					        .then(h => {
 | 
				
			||||||
 | 
					          const hh = parseFloat(h);
 | 
				
			||||||
 | 
					          expect(hh).to.be.at.least(20);
 | 
				
			||||||
 | 
					          // ensure thumb is smaller than container
 | 
				
			||||||
 | 
					          cy.get('#custom-scrollbar')
 | 
				
			||||||
 | 
					            .invoke('css', 'height')
 | 
				
			||||||
 | 
					            .then(ch => {
 | 
				
			||||||
 | 
					              expect(hh).to.be.lessThan(parseFloat(ch));
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // scroll a bit and verify thumb.top changes
 | 
				
			||||||
 | 
					      cy.get('.scroll-container').scrollTo(0, 200);
 | 
				
			||||||
 | 
					      cy.wait(50);
 | 
				
			||||||
 | 
					      cy.get('#scroll-thumb')
 | 
				
			||||||
 | 
					        .invoke('css', 'top')
 | 
				
			||||||
 | 
					        .then(t => {
 | 
				
			||||||
 | 
					          expect(parseFloat(t)).to.be.greaterThan(0);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('hides scrollbar when content fits', () => {
 | 
				
			||||||
 | 
					      // remove overflow
 | 
				
			||||||
 | 
					      cy.get('.scroll-container').then($sc => {
 | 
				
			||||||
 | 
					        $sc[0].innerHTML = '<div style="height:10px">tiny</div>';
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      cy.window().invoke('updateCustomScrollbar');
 | 
				
			||||||
 | 
					      cy.get('#custom-scrollbar').should('have.css', 'opacity', '0');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										85
									
								
								app/cypress/e2e/fullscreen.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								app/cypress/e2e/fullscreen.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,85 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/fullscreen.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Fullscreen Toggle', () => {
 | 
				
			||||||
 | 
					  const ROOT = '/';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    cy.visit(ROOT);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('defaults to normal mode when no fullscreen param is present', () => {
 | 
				
			||||||
 | 
					    // Body should not have fullscreen class
 | 
				
			||||||
 | 
					    cy.get('body').should('not.have.class', 'fullscreen');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // URL should not include `fullscreen`
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'fullscreen=');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Header and footer should be visible (max-height > 0)
 | 
				
			||||||
 | 
					    cy.get('header').should('have.css', 'max-height').and(value => {
 | 
				
			||||||
 | 
					      expect(parseFloat(value)).to.be.greaterThan(0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    cy.get('footer').should('have.css', 'max-height').and(value => {
 | 
				
			||||||
 | 
					      expect(parseFloat(value)).to.be.greaterThan(0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('initFullscreenFromUrl() picks up ?fullscreen=1 on load', () => {
 | 
				
			||||||
 | 
					    cy.visit(`${ROOT}?fullscreen=1`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.get('body').should('have.class', 'fullscreen');
 | 
				
			||||||
 | 
					    cy.url().should('include', 'fullscreen=1');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Header and footer should be collapsed (max-height == 0)
 | 
				
			||||||
 | 
					    cy.get('header').should('have.css', 'max-height', '0px');
 | 
				
			||||||
 | 
					    cy.get('footer').should('have.css', 'max-height', '0px');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('enterFullscreen() adds fullscreen class, sets full width, and updates URL', () => {
 | 
				
			||||||
 | 
					    cy.window().then(win => {
 | 
				
			||||||
 | 
					      win.exitFullscreen(); // ensure starting state
 | 
				
			||||||
 | 
					      win.enterFullscreen();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.get('body').should('have.class', 'fullscreen');
 | 
				
			||||||
 | 
					    cy.url().should('include', 'fullscreen=1');
 | 
				
			||||||
 | 
					    cy.get('.container, .container-fluid')
 | 
				
			||||||
 | 
					      .should('have.class', 'container-fluid');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.get('header').should('have.css', 'max-height', '0px');
 | 
				
			||||||
 | 
					    cy.get('footer').should('have.css', 'max-height', '0px');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('exitFullscreen() removes fullscreen class, resets width, and URL param', () => {
 | 
				
			||||||
 | 
					    // start in fullscreen
 | 
				
			||||||
 | 
					    cy.window().invoke('enterFullscreen');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // then exit
 | 
				
			||||||
 | 
					    cy.window().invoke('exitFullscreen');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.get('body').should('not.have.class', 'fullscreen');
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'fullscreen=');
 | 
				
			||||||
 | 
					    cy.get('.container, .container-fluid')
 | 
				
			||||||
 | 
					      .should('have.class', 'container')
 | 
				
			||||||
 | 
					      .and('not.have.class', 'container-fluid');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Header and footer should be expanded again
 | 
				
			||||||
 | 
					    cy.get('header').should('have.css', 'max-height').and(value => {
 | 
				
			||||||
 | 
					      expect(parseFloat(value)).to.be.greaterThan(0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    cy.get('footer').should('have.css', 'max-height').and(value => {
 | 
				
			||||||
 | 
					      expect(parseFloat(value)).to.be.greaterThan(0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('toggleFullscreen() toggles into and out of fullscreen', () => {
 | 
				
			||||||
 | 
					    // Toggle into fullscreen
 | 
				
			||||||
 | 
					    cy.window().invoke('toggleFullscreen');
 | 
				
			||||||
 | 
					    cy.get('body').should('have.class', 'fullscreen');
 | 
				
			||||||
 | 
					    cy.url().should('include', 'fullscreen=1');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Toggle back
 | 
				
			||||||
 | 
					    cy.window().invoke('toggleFullscreen');
 | 
				
			||||||
 | 
					    cy.get('body').should('not.have.class', 'fullscreen');
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'fullscreen=');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										61
									
								
								app/cypress/e2e/fullwidth.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/cypress/e2e/fullwidth.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/fullwidth.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Full-width Toggle', () => {
 | 
				
			||||||
 | 
					  // test page must include your <div class="container"> wrapper
 | 
				
			||||||
 | 
					  const ROOT = '/'; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('defaults to .container when no param is present', () => {
 | 
				
			||||||
 | 
					    cy.visit(ROOT);
 | 
				
			||||||
 | 
					    cy.get('.container, .container-fluid')
 | 
				
			||||||
 | 
					      .should('have.class', 'container')
 | 
				
			||||||
 | 
					      .and('not.have.class', 'container-fluid');
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    // URL should not include `fullwidth`
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'fullwidth=');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('initFullWidthFromUrl() picks up ?fullwidth=1 on load', () => {
 | 
				
			||||||
 | 
					    cy.visit(`${ROOT}?fullwidth=1`);
 | 
				
			||||||
 | 
					    cy.get('.container, .container-fluid')
 | 
				
			||||||
 | 
					      .should('have.class', 'container-fluid')
 | 
				
			||||||
 | 
					      .and('not.have.class', 'container');
 | 
				
			||||||
 | 
					    cy.url().should('include', 'fullwidth=1');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('setFullWidth(true) switches to container-fluid and updates URL', () => {
 | 
				
			||||||
 | 
					    cy.visit(ROOT);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // call your global function
 | 
				
			||||||
 | 
					    cy.window().invoke('setFullWidth', true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.get('.container, .container-fluid')
 | 
				
			||||||
 | 
					      .should('have.class', 'container-fluid')
 | 
				
			||||||
 | 
					      .and('not.have.class', 'container');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.url().should('include', 'fullwidth=1');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('setFullWidth(false) reverts to container and removes URL param', () => {
 | 
				
			||||||
 | 
					    cy.visit(`${ROOT}?fullwidth=1`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // now reset
 | 
				
			||||||
 | 
					    cy.window().invoke('setFullWidth', false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.get('.container, .container-fluid')
 | 
				
			||||||
 | 
					      .should('have.class', 'container')
 | 
				
			||||||
 | 
					      .and('not.have.class', 'container-fluid');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'fullwidth=1');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('updateUrlFullWidth() toggles the query param without changing layout', () => {
 | 
				
			||||||
 | 
					    cy.visit(ROOT);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // manually toggle URL only
 | 
				
			||||||
 | 
					    cy.window().invoke('updateUrlFullWidth', true);
 | 
				
			||||||
 | 
					    cy.url().should('include', 'fullwidth=1');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.window().invoke('updateUrlFullWidth', false);
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'fullwidth=');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										46
									
								
								app/cypress/e2e/iframe.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/cypress/e2e/iframe.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/iframe.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Iframe integration', () => {
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    // Visit the app’s base URL (configured in cypress.config.js)
 | 
				
			||||||
 | 
					    cy.visit('/');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('opens the iframe when an .iframe-link is clicked', () => {
 | 
				
			||||||
 | 
					    // Find the first iframe-link on the page
 | 
				
			||||||
 | 
					    cy.get('.iframe-link').first().then($link => {
 | 
				
			||||||
 | 
					      const href = $link.prop('href');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Click it
 | 
				
			||||||
 | 
					      cy.wrap($link).click();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // The URL should now include ?iframe=<encoded href>
 | 
				
			||||||
 | 
					      cy.url().should('include', 'iframe=' + encodeURIComponent(href));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // The <body> should have the "fullscreen" class
 | 
				
			||||||
 | 
					      cy.get('body').should('have.class', 'fullscreen');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // And the <main> should contain a visible <iframe src="<href>">
 | 
				
			||||||
 | 
					      cy.get('main iframe')
 | 
				
			||||||
 | 
					        .should('have.attr', 'src', href)
 | 
				
			||||||
 | 
					        .and('be.visible');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('restores the original content when a .js-restore element is clicked', () => {
 | 
				
			||||||
 | 
					    // First open the iframe
 | 
				
			||||||
 | 
					    cy.get('.iframe-link').first().click();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Then click the first .js-restore element (e.g. header or logo)
 | 
				
			||||||
 | 
					    cy.get('.js-restore').first().click();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // The URL must no longer include the iframe parameter
 | 
				
			||||||
 | 
					    cy.url().should('not.include', 'iframe=');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // The <body> should no longer have the "fullscreen" class
 | 
				
			||||||
 | 
					    cy.get('body').should('not.have.class', 'fullscreen');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // And no <iframe> should remain inside <main>
 | 
				
			||||||
 | 
					    cy.get('main iframe').should('not.exist');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										130
									
								
								app/cypress/e2e/menu.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								app/cypress/e2e/menu.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/dynamic_popup.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Dynamic Popup', () => {
 | 
				
			||||||
 | 
					  const base = {
 | 
				
			||||||
 | 
					    name: 'Test Item',
 | 
				
			||||||
 | 
					    identifier: 'ABC123',
 | 
				
			||||||
 | 
					    description: 'A simple description',
 | 
				
			||||||
 | 
					    warning: '**Be careful!**',
 | 
				
			||||||
 | 
					    info: '_Some info_',
 | 
				
			||||||
 | 
					    url: null,
 | 
				
			||||||
 | 
					    iframe: false,
 | 
				
			||||||
 | 
					    icon: { class: 'fa fa-test' },
 | 
				
			||||||
 | 
					    alternatives: [
 | 
				
			||||||
 | 
					      { name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    children: [
 | 
				
			||||||
 | 
					      { name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    cy.visit('/');
 | 
				
			||||||
 | 
					    cy.window().then(win => {
 | 
				
			||||||
 | 
					      cy.stub(win.navigator.clipboard, 'writeText').resolves();
 | 
				
			||||||
 | 
					      cy.stub(win, 'alert');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function open(item = {}) {
 | 
				
			||||||
 | 
					    cy.window().invoke('openDynamicPopup', { ...base, ...item });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders title with icon and text', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .find('i.fa.fa-test')
 | 
				
			||||||
 | 
					      .should('exist');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('contain.text', 'Test Item');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('falls back to plain text when no icon', () => {
 | 
				
			||||||
 | 
					    open({ icon: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .find('i')
 | 
				
			||||||
 | 
					      .should('not.exist');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('have.text', 'Test Item');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows identifier when provided and populates input', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalContent').should('have.value', 'ABC123');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides identifier box when none', () => {
 | 
				
			||||||
 | 
					    open({ identifier: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalContent').should('have.value', '');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders warning and info via marked', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalWarning')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .find('#dynamicModalWarningText')
 | 
				
			||||||
 | 
					      .should('contain.html', '<strong>Be careful!</strong>');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalInfo')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .find('#dynamicModalInfoText')
 | 
				
			||||||
 | 
					      .should('contain.html', '<em>Some info</em>');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides warning/info when none provided', () => {
 | 
				
			||||||
 | 
					    open({ warning: null, info: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalWarning').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalInfo').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows description when no URL', () => {
 | 
				
			||||||
 | 
					    open({ url: null, description: 'Only desc' });
 | 
				
			||||||
 | 
					    cy.get('#dynamicDescriptionText')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .and('have.text', 'Only desc');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLink').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows link when URL is provided', () => {
 | 
				
			||||||
 | 
					    open({ url: 'https://example.com', description: 'Click me' });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLinkHref')
 | 
				
			||||||
 | 
					      .should('have.attr', 'href', 'https://example.com')
 | 
				
			||||||
 | 
					      .and('have.text', 'Click me');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('populates alternatives and children lists', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesList li')
 | 
				
			||||||
 | 
					      .should('have.length', 1)
 | 
				
			||||||
 | 
					      .first().contains('Alt One');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenList li')
 | 
				
			||||||
 | 
					      .should('have.length', 1)
 | 
				
			||||||
 | 
					      .first().contains('Child One');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides sections when no items', () => {
 | 
				
			||||||
 | 
					    open({ alternatives: [], children: [] });
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('clicking an “Open” in list re-opens popup with that item', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesList button').click();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('contain.text', 'Alt One');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('copy button selects & copies identifier', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicCopyButton').click();
 | 
				
			||||||
 | 
					    cy.window().its('navigator.clipboard.writeText')
 | 
				
			||||||
 | 
					      .should('have.been.calledWith', 'ABC123');
 | 
				
			||||||
 | 
					    cy.window().its('alert')
 | 
				
			||||||
 | 
					      .should('have.been.calledWith', 'Identifier copied to clipboard!');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										130
									
								
								app/cypress/e2e/modal.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								app/cypress/e2e/modal.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/dynamic_popup.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Dynamic Popup', () => {
 | 
				
			||||||
 | 
					  const base = {
 | 
				
			||||||
 | 
					    name: 'Test Item',
 | 
				
			||||||
 | 
					    identifier: 'ABC123',
 | 
				
			||||||
 | 
					    description: 'A simple description',
 | 
				
			||||||
 | 
					    warning: '**Be careful!**',
 | 
				
			||||||
 | 
					    info: '_Some info_',
 | 
				
			||||||
 | 
					    url: null,
 | 
				
			||||||
 | 
					    iframe: false,
 | 
				
			||||||
 | 
					    icon: { class: 'fa fa-test' },
 | 
				
			||||||
 | 
					    alternatives: [
 | 
				
			||||||
 | 
					      { name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    children: [
 | 
				
			||||||
 | 
					      { name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    cy.visit('/');
 | 
				
			||||||
 | 
					    cy.window().then(win => {
 | 
				
			||||||
 | 
					      cy.stub(win.navigator.clipboard, 'writeText').resolves();
 | 
				
			||||||
 | 
					      cy.stub(win, 'alert');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function open(item = {}) {
 | 
				
			||||||
 | 
					    cy.window().invoke('openDynamicPopup', { ...base, ...item });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders title with icon and text', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .find('i.fa.fa-test')
 | 
				
			||||||
 | 
					      .should('exist');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('contain.text', 'Test Item');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('falls back to plain text when no icon', () => {
 | 
				
			||||||
 | 
					    open({ icon: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .find('i')
 | 
				
			||||||
 | 
					      .should('not.exist');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('have.text', 'Test Item');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows identifier when provided and populates input', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalContent').should('have.value', 'ABC123');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides identifier box when none', () => {
 | 
				
			||||||
 | 
					    open({ identifier: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalContent').should('have.value', '');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders warning and info via marked', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalWarning')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .find('#dynamicModalWarningText')
 | 
				
			||||||
 | 
					      .should('contain.html', '<strong>Be careful!</strong>');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalInfo')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .find('#dynamicModalInfoText')
 | 
				
			||||||
 | 
					      .should('contain.html', '<em>Some info</em>');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides warning/info when none provided', () => {
 | 
				
			||||||
 | 
					    open({ warning: null, info: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalWarning').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalInfo').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows description when no URL', () => {
 | 
				
			||||||
 | 
					    open({ url: null, description: 'Only desc' });
 | 
				
			||||||
 | 
					    cy.get('#dynamicDescriptionText')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .and('have.text', 'Only desc');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLink').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows link when URL is provided', () => {
 | 
				
			||||||
 | 
					    open({ url: 'https://example.com', description: 'Click me' });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLinkHref')
 | 
				
			||||||
 | 
					      .should('have.attr', 'href', 'https://example.com')
 | 
				
			||||||
 | 
					      .and('have.text', 'Click me');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('populates alternatives and children lists', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesList li')
 | 
				
			||||||
 | 
					      .should('have.length', 1)
 | 
				
			||||||
 | 
					      .first().contains('Alt One');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenList li')
 | 
				
			||||||
 | 
					      .should('have.length', 1)
 | 
				
			||||||
 | 
					      .first().contains('Child One');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides sections when no items', () => {
 | 
				
			||||||
 | 
					    open({ alternatives: [], children: [] });
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('clicking an “Open” in list re-opens popup with that item', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesList button').click();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('contain.text', 'Alt One');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('copy button selects & copies identifier', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicCopyButton').click();
 | 
				
			||||||
 | 
					    cy.window().its('navigator.clipboard.writeText')
 | 
				
			||||||
 | 
					      .should('have.been.calledWith', 'ABC123');
 | 
				
			||||||
 | 
					    cy.window().its('alert')
 | 
				
			||||||
 | 
					      .should('have.been.calledWith', 'Identifier copied to clipboard!');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										130
									
								
								app/cypress/e2e/tooltips.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								app/cypress/e2e/tooltips.spec.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					// cypress/e2e/dynamic_popup.spec.js
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('Dynamic Popup', () => {
 | 
				
			||||||
 | 
					  const base = {
 | 
				
			||||||
 | 
					    name: 'Test Item',
 | 
				
			||||||
 | 
					    identifier: 'ABC123',
 | 
				
			||||||
 | 
					    description: 'A simple description',
 | 
				
			||||||
 | 
					    warning: '**Be careful!**',
 | 
				
			||||||
 | 
					    info: '_Some info_',
 | 
				
			||||||
 | 
					    url: null,
 | 
				
			||||||
 | 
					    iframe: false,
 | 
				
			||||||
 | 
					    icon: { class: 'fa fa-test' },
 | 
				
			||||||
 | 
					    alternatives: [
 | 
				
			||||||
 | 
					      { name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    children: [
 | 
				
			||||||
 | 
					      { name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    cy.visit('/');
 | 
				
			||||||
 | 
					    cy.window().then(win => {
 | 
				
			||||||
 | 
					      cy.stub(win.navigator.clipboard, 'writeText').resolves();
 | 
				
			||||||
 | 
					      cy.stub(win, 'alert');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function open(item = {}) {
 | 
				
			||||||
 | 
					    cy.window().invoke('openDynamicPopup', { ...base, ...item });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders title with icon and text', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .find('i.fa.fa-test')
 | 
				
			||||||
 | 
					      .should('exist');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('contain.text', 'Test Item');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('falls back to plain text when no icon', () => {
 | 
				
			||||||
 | 
					    open({ icon: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .find('i')
 | 
				
			||||||
 | 
					      .should('not.exist');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('have.text', 'Test Item');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows identifier when provided and populates input', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalContent').should('have.value', 'ABC123');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides identifier box when none', () => {
 | 
				
			||||||
 | 
					    open({ identifier: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalContent').should('have.value', '');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders warning and info via marked', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalWarning')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .find('#dynamicModalWarningText')
 | 
				
			||||||
 | 
					      .should('contain.html', '<strong>Be careful!</strong>');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalInfo')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .find('#dynamicModalInfoText')
 | 
				
			||||||
 | 
					      .should('contain.html', '<em>Some info</em>');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides warning/info when none provided', () => {
 | 
				
			||||||
 | 
					    open({ warning: null, info: null });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalWarning').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalInfo').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows description when no URL', () => {
 | 
				
			||||||
 | 
					    open({ url: null, description: 'Only desc' });
 | 
				
			||||||
 | 
					    cy.get('#dynamicDescriptionText')
 | 
				
			||||||
 | 
					      .should('not.have.class', 'd-none')
 | 
				
			||||||
 | 
					      .and('have.text', 'Only desc');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLink').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('shows link when URL is provided', () => {
 | 
				
			||||||
 | 
					    open({ url: 'https://example.com', description: 'Click me' });
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLinkHref')
 | 
				
			||||||
 | 
					      .should('have.attr', 'href', 'https://example.com')
 | 
				
			||||||
 | 
					      .and('have.text', 'Click me');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('populates alternatives and children lists', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesList li')
 | 
				
			||||||
 | 
					      .should('have.length', 1)
 | 
				
			||||||
 | 
					      .first().contains('Alt One');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenList li')
 | 
				
			||||||
 | 
					      .should('have.length', 1)
 | 
				
			||||||
 | 
					      .first().contains('Child One');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('hides sections when no items', () => {
 | 
				
			||||||
 | 
					    open({ alternatives: [], children: [] });
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					    cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('clicking an “Open” in list re-opens popup with that item', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicAlternativesList button').click();
 | 
				
			||||||
 | 
					    cy.get('#dynamicModalLabel')
 | 
				
			||||||
 | 
					      .should('contain.text', 'Alt One');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('copy button selects & copies identifier', () => {
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    cy.get('#dynamicCopyButton').click();
 | 
				
			||||||
 | 
					    cy.window().its('navigator.clipboard.writeText')
 | 
				
			||||||
 | 
					      .should('have.been.calledWith', 'ABC123');
 | 
				
			||||||
 | 
					    cy.window().its('alert')
 | 
				
			||||||
 | 
					      .should('have.been.calledWith', 'Identifier copied to clipboard!');
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										5
									
								
								app/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "cypress": "^14.5.1"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -44,8 +44,8 @@ function enterFullscreen() {
 | 
				
			|||||||
  // fade in logo… (unchanged)
 | 
					  // fade in logo… (unchanged)
 | 
				
			||||||
  const logo = document.getElementById('navbar_logo');
 | 
					  const logo = document.getElementById('navbar_logo');
 | 
				
			||||||
  if (logo) {
 | 
					  if (logo) {
 | 
				
			||||||
    logo.classList.remove('d-none');
 | 
					    // hide the navbar‐logo restore link in fullscreen
 | 
				
			||||||
    requestAnimationFrame(() => logo.style.opacity = '1');
 | 
					    logo.classList.add('d-none');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // now recalc in lock-step with the CSS collapse animation
 | 
					  // now recalc in lock-step with the CSS collapse animation
 | 
				
			||||||
@@ -57,16 +57,10 @@ function exitFullscreen() {
 | 
				
			|||||||
  setFullWidth(false);
 | 
					  setFullWidth(false);
 | 
				
			||||||
  updateUrlFullscreen(false);
 | 
					  updateUrlFullscreen(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // fade out logo… (unchanged)
 | 
					 | 
				
			||||||
  const logo = document.getElementById('navbar_logo');
 | 
					  const logo = document.getElementById('navbar_logo');
 | 
				
			||||||
  if (logo) {
 | 
					  if (logo) {
 | 
				
			||||||
    logo.style.opacity = '0';
 | 
					    // show the navbar‐logo restore link again
 | 
				
			||||||
    logo.addEventListener('transitionend', function handler(e) {
 | 
					    logo.classList.remove('d-none');
 | 
				
			||||||
      if (e.propertyName === 'opacity') {
 | 
					 | 
				
			||||||
        logo.classList.add('d-none');
 | 
					 | 
				
			||||||
        logo.removeEventListener('transitionend', handler);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // recalc while header/footer expand back
 | 
					  // recalc while header/footer expand back
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,16 @@
 | 
				
			|||||||
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
 | 
					let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
 | 
				
			||||||
let currentIframeUrl = null;
 | 
					let currentIframeUrl = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// === Auto-open iframe if URL parameter is present ===
 | 
				
			||||||
 | 
					window.addEventListener('DOMContentLoaded', () => {
 | 
				
			||||||
 | 
					  const paramUrl = new URLSearchParams(window.location.search).get('iframe');
 | 
				
			||||||
 | 
					  if (paramUrl) {
 | 
				
			||||||
 | 
					    currentIframeUrl = paramUrl;
 | 
				
			||||||
 | 
					    enterFullscreen();
 | 
				
			||||||
 | 
					    openIframe(paramUrl);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Synchronize the height of the iframe to match the scroll-container or main element
 | 
					// Synchronize the height of the iframe to match the scroll-container or main element
 | 
				
			||||||
function syncIframeHeight() {
 | 
					function syncIframeHeight() {
 | 
				
			||||||
    const iframe = mainElement.querySelector("iframe");
 | 
					    const iframe = mainElement.querySelector("iframe");
 | 
				
			||||||
@@ -24,8 +34,6 @@ function syncIframeHeight() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
 | 
					// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
 | 
				
			||||||
function openIframe(url) {
 | 
					function openIframe(url) {
 | 
				
			||||||
    enterFullscreen();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    var $container    = scrollbarContainer    ? $(scrollbarContainer)    : null;
 | 
					    var $container    = scrollbarContainer    ? $(scrollbarContainer)    : null;
 | 
				
			||||||
    var $customScroll = customScrollbar       ? $(customScrollbar)       : null;
 | 
					    var $customScroll = customScrollbar       ? $(customScrollbar)       : null;
 | 
				
			||||||
    var $main         = $(mainElement);
 | 
					    var $main         = $(mainElement);
 | 
				
			||||||
@@ -36,7 +44,9 @@ function openIframe(url) {
 | 
				
			|||||||
    if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
 | 
					    if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    $.when.apply($, promises).done(function() {
 | 
					    $.when.apply($, promises).done(function() {
 | 
				
			||||||
        // Iframe anlegen, falls noch nicht vorhanden
 | 
					        // now that scroll areas are hidden, go fullscreen
 | 
				
			||||||
 | 
					        enterFullscreen();
 | 
				
			||||||
 | 
					        // create iframe if it doesn’t exist yet
 | 
				
			||||||
        var $iframe = $main.find('iframe');
 | 
					        var $iframe = $main.find('iframe');
 | 
				
			||||||
        if ($iframe.length === 0) {
 | 
					        if ($iframe.length === 0) {
 | 
				
			||||||
            originalMainStyle = $main.attr('style') || null;
 | 
					            originalMainStyle = $main.attr('style') || null;
 | 
				
			||||||
@@ -63,36 +73,31 @@ function openIframe(url) {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Function to restore the original content (jQuery version mit 1500 ms Fade)
 | 
					/**
 | 
				
			||||||
 | 
					 * Restore the original <main> content and exit fullscreen.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
function restoreOriginal() {
 | 
					function restoreOriginal() {
 | 
				
			||||||
    var $main   = $(mainElement);
 | 
					  // Exit fullscreen (collapse header/footer and run recalcs)
 | 
				
			||||||
    var $iframe = $main.find('iframe');
 | 
					  exitFullscreen();
 | 
				
			||||||
    var $container    = scrollbarContainer    ? $(scrollbarContainer)    : null;
 | 
					 | 
				
			||||||
    var $customScroll = customScrollbar       ? $(customScrollbar)       : null;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if ($iframe.length) {
 | 
					  // Replace <main> innerHTML with the snapshot we took on load
 | 
				
			||||||
        // Iframe mit 1500 ms ausblenden, dann entfernen und Original einblenden
 | 
					  mainElement.innerHTML = originalContent;
 | 
				
			||||||
        $iframe.fadeOut(1500, function() {
 | 
					 | 
				
			||||||
            $iframe.remove();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if ($container)    $container.fadeIn(1500);
 | 
					  // Reset any inline styles on mainElement
 | 
				
			||||||
            if ($customScroll) $customScroll.fadeIn(1500);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Inline-Style des main-Elements zurücksetzen
 | 
					 | 
				
			||||||
  if (originalMainStyle !== null) {
 | 
					  if (originalMainStyle !== null) {
 | 
				
			||||||
                $main.attr('style', originalMainStyle);
 | 
					    mainElement.setAttribute('style', originalMainStyle);
 | 
				
			||||||
  } else {
 | 
					  } else {
 | 
				
			||||||
                $main.removeAttr('style');
 | 
					    mainElement.removeAttribute('style');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // URL-Parameter entfernen
 | 
					  // Re-run height adjustments for scroll container & thumb
 | 
				
			||||||
            var newUrl = new URL(window.location);
 | 
					  adjustScrollContainerHeight();
 | 
				
			||||||
            newUrl.searchParams.delete('iframe');
 | 
					  updateCustomScrollbar();
 | 
				
			||||||
            window.history.pushState({}, '', newUrl);
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Clear iframe state and URL param
 | 
				
			||||||
 | 
					  currentIframeUrl = null;
 | 
				
			||||||
 | 
					  history.replaceState(null, '', window.location.pathname);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Initialize event listeners after DOM content is loaded
 | 
					// Initialize event listeners after DOM content is loaded
 | 
				
			||||||
document.addEventListener("DOMContentLoaded", function() {
 | 
					document.addEventListener("DOMContentLoaded", function() {
 | 
				
			||||||
@@ -109,14 +114,18 @@ document.addEventListener("DOMContentLoaded", function() {
 | 
				
			|||||||
        el.addEventListener("click", restoreOriginal);
 | 
					        el.addEventListener("click", restoreOriginal);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // === Close iframe & exit fullscreen on any .js-restore click ===
 | 
					    // === Close iframe & exit fullscreen when any .js-restore is clicked ===
 | 
				
			||||||
    document.body.addEventListener('click', e => {
 | 
					    document.querySelectorAll('.js-restore').forEach(el => {
 | 
				
			||||||
    if (e.target.closest('.js-restore')) {
 | 
					      el.style.cursor = 'pointer';
 | 
				
			||||||
        restoreOriginal();
 | 
					      el.addEventListener('click', () => {
 | 
				
			||||||
 | 
					        // first collapse header/footer and recalc container
 | 
				
			||||||
        exitFullscreen();
 | 
					        exitFullscreen();
 | 
				
			||||||
 | 
					        // then fade out and remove the iframe, fade content back
 | 
				
			||||||
 | 
					        restoreOriginal();
 | 
				
			||||||
 | 
					        // clear stored URL and reset browser address
 | 
				
			||||||
        currentIframeUrl = null;
 | 
					        currentIframeUrl = null;
 | 
				
			||||||
        history.replaceState(null, '', window.location.pathname);
 | 
					        history.replaceState(null, '', window.location.pathname);
 | 
				
			||||||
    }
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
@@ -163,12 +172,18 @@ function observeIframeNavigation() {
 | 
				
			|||||||
  }, 500);
 | 
					  }, 500);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Remember and open via central toggle
 | 
					// Remember, open iframe, enter fullscreen, AND set the URL param immediately
 | 
				
			||||||
document.querySelectorAll(".iframe-link").forEach(link => {
 | 
					document.querySelectorAll(".iframe-link").forEach(link => {
 | 
				
			||||||
  link.addEventListener("click", function(event) {
 | 
					  link.addEventListener("click", function(event) {
 | 
				
			||||||
    event.preventDefault();
 | 
					    event.preventDefault();
 | 
				
			||||||
    currentIframeUrl = this.href;
 | 
					    currentIframeUrl = this.href;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    enterFullscreen();
 | 
					    enterFullscreen();
 | 
				
			||||||
    openIframe(currentIframeUrl);
 | 
					    openIframe(currentIframeUrl);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update the browser URL right away
 | 
				
			||||||
 | 
					    const newUrl = new URL(window.location);
 | 
				
			||||||
 | 
					    newUrl.searchParams.set('iframe', currentIframeUrl);
 | 
				
			||||||
 | 
					    window.history.replaceState({ iframe: currentIframeUrl }, '', newUrl);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,2 +1,2 @@
 | 
				
			|||||||
PORT=5000
 | 
					PORT=5001
 | 
				
			||||||
FLASK_ENV=production
 | 
					FLASK_ENV=production
 | 
				
			||||||
							
								
								
									
										300
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										300
									
								
								main.py
									
									
									
									
									
								
							@@ -1,300 +1,84 @@
 | 
				
			|||||||
#!/usr/bin/env python3
 | 
					#!/usr/bin/env python3
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
main.py - A CLI tool for managing the Portfolio CMS Docker application.
 | 
					main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
 | 
				
			||||||
 | 
					Automatically generates CLI commands based on the Makefile definitions.
 | 
				
			||||||
This script provides commands to build and run the Docker container for the
 | 
					 | 
				
			||||||
portfolio application. It mimics the functionality of a Makefile with additional
 | 
					 | 
				
			||||||
explanatory text using argparse.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Commands:
 | 
					 | 
				
			||||||
    build     - Build the Docker image.
 | 
					 | 
				
			||||||
    up        - Start the application using docker-compose (with build).
 | 
					 | 
				
			||||||
    down      - Stop and remove the running container.
 | 
					 | 
				
			||||||
    run-dev   - Run the container in development mode (with hot-reloading).
 | 
					 | 
				
			||||||
    run-prod  - Run the container in production mode.
 | 
					 | 
				
			||||||
    logs      - Display the logs of the running container.
 | 
					 | 
				
			||||||
    dev       - Start the application in development mode using docker-compose.
 | 
					 | 
				
			||||||
    prod      - Start the application in production mode using docker-compose.
 | 
					 | 
				
			||||||
    cleanup   - Remove all stopped containers.
 | 
					 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					 | 
				
			||||||
import argparse
 | 
					import argparse
 | 
				
			||||||
import subprocess
 | 
					import subprocess
 | 
				
			||||||
import sys
 | 
					import sys
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
from dotenv import load_dotenv
 | 
					import re
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
dotenv_path = Path(__file__).resolve().parent / ".env"
 | 
					MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if dotenv_path.exists():
 | 
					 | 
				
			||||||
    load_dotenv(dotenv_path)
 | 
					 | 
				
			||||||
else:
 | 
					 | 
				
			||||||
    print(f"⚠️  Warning: No .env file found at {dotenv_path}")
 | 
					 | 
				
			||||||
PORT = int(os.getenv("PORT", 5000))
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
def run_command(command, dry_run=False, env=None):
 | 
					def load_targets(makefile_path):
 | 
				
			||||||
    """Utility function to run a shell command."""
 | 
					    """
 | 
				
			||||||
 | 
					    Parse the Makefile to extract targets and their help comments.
 | 
				
			||||||
 | 
					    Assumes each target is defined as 'name:' and the following line that starts
 | 
				
			||||||
 | 
					    with '\t#' provides its help text.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    targets = []
 | 
				
			||||||
 | 
					    pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
 | 
				
			||||||
 | 
					    with open(makefile_path, 'r') as f:
 | 
				
			||||||
 | 
					        lines = f.readlines()
 | 
				
			||||||
 | 
					    for idx, line in enumerate(lines):
 | 
				
			||||||
 | 
					        m = pattern.match(line)
 | 
				
			||||||
 | 
					        if m:
 | 
				
			||||||
 | 
					            name = m.group(1)
 | 
				
			||||||
 | 
					            help_text = ''
 | 
				
			||||||
 | 
					            # look for next non-empty line
 | 
				
			||||||
 | 
					            if idx + 1 < len(lines) and lines[idx+1].lstrip().startswith('#'):
 | 
				
			||||||
 | 
					                help_text = lines[idx+1].lstrip('# ').strip()
 | 
				
			||||||
 | 
					            targets.append((name, help_text))
 | 
				
			||||||
 | 
					    return targets
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def run_command(command, dry_run=False):
 | 
				
			||||||
 | 
					    """Utility to run shell commands."""
 | 
				
			||||||
    print(f"Executing: {' '.join(command)}")
 | 
					    print(f"Executing: {' '.join(command)}")
 | 
				
			||||||
    if dry_run:
 | 
					    if dry_run:
 | 
				
			||||||
        print("Dry run enabled: command not executed.")
 | 
					        print("Dry run enabled: command not executed.")
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        subprocess.check_call(command, env=env)
 | 
					        subprocess.check_call(command)
 | 
				
			||||||
    except subprocess.CalledProcessError as e:
 | 
					    except subprocess.CalledProcessError as e:
 | 
				
			||||||
        print(f"Error: Command failed with exit code {e.returncode}")
 | 
					        print(f"Error: Command failed with exit code {e.returncode}")
 | 
				
			||||||
        sys.exit(e.returncode)
 | 
					        sys.exit(e.returncode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def build(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Build the Docker image for the portfolio application.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker build -t application-portfolio .
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command creates a Docker image named 'application-portfolio'
 | 
					 | 
				
			||||||
    from the Dockerfile in the current directory.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = ["docker", "build", "-t", "application-portfolio", "."]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def up(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Start the application using docker-compose with build.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker-compose up --build
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command uses docker-compose to build (if necessary) and start
 | 
					 | 
				
			||||||
    all defined services. It is useful for quickly starting your
 | 
					 | 
				
			||||||
    development or production environment.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = ["docker-compose", "up", "--build"]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def down(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Stop and remove the Docker container named 'portfolio'.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Commands:
 | 
					 | 
				
			||||||
      docker stop portfolio
 | 
					 | 
				
			||||||
      docker rm portfolio
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    These commands stop the running container and remove it from your Docker host.
 | 
					 | 
				
			||||||
    The '-' prefix is used to ignore errors if the container is not running.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command_stop = ["docker", "stop", "portfolio"]
 | 
					 | 
				
			||||||
    command_rm = ["docker", "rm", "portfolio"]
 | 
					 | 
				
			||||||
    run_command(command_stop, args.dry_run)
 | 
					 | 
				
			||||||
    run_command(command_rm, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def run_dev(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Run the container in development mode with hot-reloading.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker run -d -p 5000:5000 --name portfolio -v $(pwd)/app/:/app \
 | 
					 | 
				
			||||||
      -e FLASK_APP=app.py -e FLASK_ENV=development application-portfolio
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command starts the container in detached mode (-d), maps port 5000,
 | 
					 | 
				
			||||||
    mounts the local 'app/' directory into the container, and sets environment
 | 
					 | 
				
			||||||
    variables to enable Flask's development mode.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    current_dir = os.getcwd()
 | 
					 | 
				
			||||||
    volume_mapping = f"{current_dir}/app/:/app"
 | 
					 | 
				
			||||||
    command = [
 | 
					 | 
				
			||||||
        "docker", "run", "-d",
 | 
					 | 
				
			||||||
        "-p", f"{PORT}:{PORT}",
 | 
					 | 
				
			||||||
        "--name", "portfolio",
 | 
					 | 
				
			||||||
        "-v", volume_mapping,
 | 
					 | 
				
			||||||
        "-e", "FLASK_APP=app.py",
 | 
					 | 
				
			||||||
        "-e", "FLASK_ENV=development",
 | 
					 | 
				
			||||||
        "application-portfolio"
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def run_prod(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Run the container in production mode.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker run -d -p 5000:5000 --name portfolio application-portfolio
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command starts the container in detached mode, mapping port 5000,
 | 
					 | 
				
			||||||
    and runs the production version of the portfolio application.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = [
 | 
					 | 
				
			||||||
        "docker", "run", "-d",
 | 
					 | 
				
			||||||
        "-p", "{PORT}:5000",
 | 
					 | 
				
			||||||
        "--name", "portfolio",
 | 
					 | 
				
			||||||
        "application-portfolio"
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def logs(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Display the logs of the 'portfolio' container.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker logs -f portfolio
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command follows the logs (using -f) of the running container,
 | 
					 | 
				
			||||||
    which is helpful for debugging and monitoring.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = ["docker", "logs", "-f", "portfolio"]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def dev(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Run the application in development mode using docker-compose.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    env = os.environ.copy()
 | 
					 | 
				
			||||||
    env["FLASK_ENV"] = "development"
 | 
					 | 
				
			||||||
    command = ["docker-compose", "up", "-d"]
 | 
					 | 
				
			||||||
    print("▶️ Starting in development mode (FLASK_ENV=development)")
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run, env=env)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def prod(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Run the application in production mode using docker-compose.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker-compose up --build
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command builds the Docker image if needed and starts the application
 | 
					 | 
				
			||||||
    using docker-compose for a production environment.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = ["docker-compose", "up", "--build"]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def cleanup(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Remove all stopped Docker containers.
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      docker container prune -f
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
    This command cleans up your Docker environment by forcefully removing
 | 
					 | 
				
			||||||
    all stopped containers. It is useful to reclaim disk space and remove
 | 
					 | 
				
			||||||
    unused containers.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = ["docker", "container", "prune", "-f"]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
def delete_portfolio_container(dry_run=False):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Force remove the portfolio container if it exists.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    print("Checking if 'portfolio' container exists to delete...")
 | 
					 | 
				
			||||||
    command = ["docker", "rm", "-f", "portfolio"]
 | 
					 | 
				
			||||||
    run_command(command, dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def browse(args):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Open http://localhost:5000 in Chromium browser.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Command:
 | 
					 | 
				
			||||||
      chromium http://localhost:5000
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    This command launches the Chromium browser to view the running application.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    command = ["chromium", f"http://localhost:{PORT}"]
 | 
					 | 
				
			||||||
    run_command(command, args.dry_run)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
def main():
 | 
					def main():
 | 
				
			||||||
    parser = argparse.ArgumentParser(
 | 
					    parser = argparse.ArgumentParser(
 | 
				
			||||||
        description="CLI tool to manage the Portfolio CMS Docker application."
 | 
					        description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    parser.add_argument(
 | 
					    parser.add_argument(
 | 
				
			||||||
        "--dry-run",
 | 
					        "--dry-run",
 | 
				
			||||||
        action="store_true",
 | 
					        action="store_true",
 | 
				
			||||||
        help="Print the commands without executing them."
 | 
					        help="Print the generated Make command without executing it."
 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    parser.add_argument(
 | 
					 | 
				
			||||||
        "--delete",
 | 
					 | 
				
			||||||
        action="store_true",
 | 
					 | 
				
			||||||
        help="Delete the existing 'portfolio' container before running the command."
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    subparsers = parser.add_subparsers(
 | 
					    subparsers = parser.add_subparsers(
 | 
				
			||||||
        title="Commands",
 | 
					        title="Available commands",
 | 
				
			||||||
        description="Available commands to manage the application",
 | 
					        dest="command",
 | 
				
			||||||
        dest="command"
 | 
					        required=True
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Browse command
 | 
					    targets = load_targets(MAKEFILE_PATH)
 | 
				
			||||||
    parser_browse = subparsers.add_parser(
 | 
					    for name, help_text in targets:
 | 
				
			||||||
        "browse", help="Open application in Chromium browser."
 | 
					        sp = subparsers.add_parser(name, help=help_text)
 | 
				
			||||||
    )
 | 
					        sp.set_defaults(target=name)
 | 
				
			||||||
    parser_browse.set_defaults(func=browse)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Build command
 | 
					 | 
				
			||||||
    parser_build = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "build", help="Build the Docker image."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_build.set_defaults(func=build)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Up command (docker-compose up)
 | 
					 | 
				
			||||||
    parser_up = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "up", help="Start the application using docker-compose (with build)."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_up.set_defaults(func=up)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Down command
 | 
					 | 
				
			||||||
    parser_down = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "down", help="Stop and remove the Docker container."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_down.set_defaults(func=down)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Run-dev command
 | 
					 | 
				
			||||||
    parser_run_dev = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "run-dev", help="Run the container in development mode (with hot-reloading)."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_run_dev.set_defaults(func=run_dev)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Run-prod command
 | 
					 | 
				
			||||||
    parser_run_prod = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "run-prod", help="Run the container in production mode."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_run_prod.set_defaults(func=run_prod)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Logs command
 | 
					 | 
				
			||||||
    parser_logs = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "logs", help="Display the logs of the running container."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_logs.set_defaults(func=logs)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Dev command (docker-compose with FLASK_ENV)
 | 
					 | 
				
			||||||
    parser_dev = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "dev", help="Start the application in development mode using docker-compose."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_dev.set_defaults(func=dev)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Prod command (docker-compose production)
 | 
					 | 
				
			||||||
    parser_prod = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "prod", help="Start the application in production mode using docker-compose."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_prod.set_defaults(func=prod)
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Cleanup command
 | 
					 | 
				
			||||||
    parser_cleanup = subparsers.add_parser(
 | 
					 | 
				
			||||||
        "cleanup", help="Remove all stopped Docker containers."
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    parser_cleanup.set_defaults(func=cleanup)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    args = parser.parse_args()
 | 
					    args = parser.parse_args()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if args.command is None:
 | 
					    if not args.command:
 | 
				
			||||||
        parser.print_help()
 | 
					        parser.print_help()
 | 
				
			||||||
        sys.exit(1)
 | 
					        sys.exit(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if args.delete:
 | 
					    cmd = ["make", args.target]
 | 
				
			||||||
        delete_portfolio_container(args.dry_run)
 | 
					    run_command(cmd, dry_run=args.dry_run)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Execute the chosen subcommand function
 | 
					 | 
				
			||||||
    args.func(args)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
if __name__ == "__main__":
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    from pathlib import Path
 | 
				
			||||||
    main()
 | 
					    main()
 | 
				
			||||||
		Reference in New Issue
	
	Block a user