mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2025-07-19 15:44:24 +02:00
Added cypress tests
This commit is contained in:
parent
6ed3e60dd0
commit
7bc0f32145
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,37 +73,32 @@ 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);
|
if (originalMainStyle !== null) {
|
||||||
|
mainElement.setAttribute('style', originalMainStyle);
|
||||||
|
} else {
|
||||||
|
mainElement.removeAttribute('style');
|
||||||
|
}
|
||||||
|
|
||||||
// Inline-Style des main-Elements zurücksetzen
|
// Re-run height adjustments for scroll container & thumb
|
||||||
if (originalMainStyle !== null) {
|
adjustScrollContainerHeight();
|
||||||
$main.attr('style', originalMainStyle);
|
updateCustomScrollbar();
|
||||||
} else {
|
|
||||||
$main.removeAttr('style');
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL-Parameter entfernen
|
// Clear iframe state and URL param
|
||||||
var newUrl = new URL(window.location);
|
currentIframeUrl = null;
|
||||||
newUrl.searchParams.delete('iframe');
|
history.replaceState(null, '', window.location.pathname);
|
||||||
window.history.pushState({}, '', newUrl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Initialize event listeners after DOM content is loaded
|
// Initialize event listeners after DOM content is loaded
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
// Cache references to elements and original state
|
// Cache references to elements and original state
|
||||||
@ -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
|
308
main.py
308
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__":
|
||||||
main()
|
from pathlib import Path
|
||||||
|
main()
|
Loading…
x
Reference in New Issue
Block a user