build(make): run e2e without act, lint yaml, js and shell
test-e2e drove Cypress through act, which fails to start where it cannot resolve a host address for its artifact server. It now starts Flask and Cypress in one shell via scripts/run-e2e.sh, so both share a network namespace; the act path stays available as test-e2e-act. The script guards the cases that made the old target lie: it aborts when something already serves the port instead of testing that server, checks that its own Flask is alive before trusting a response, pins Cypress to the same origin Flask binds, and drops ELECTRON_RUN_AS_NODE, which VS Code exports and which makes Cypress' bundled Electron reject its own flags. 30 YAML and 18 JavaScript files had no linter. yamllint runs correctness rules only, because the repository predates it and its cosmetic findings would be noise; key-duplicates is the one that earns its keep, since PyYAML keeps the last of two identical keys without complaining. eslint runs the recommended set and already found a dead getBoundingClientRect() call in navigation.js. Both get a CI job so make lint and the workflows stop diverging. flask>=3.1 because app.config["TRUSTED_HOSTS"] arrived in 3.1 and an older Flask accepts the key and ignores it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
.github/workflows/lint.yml
vendored
55
.github/workflows/lint.yml
vendored
@@ -19,6 +19,61 @@ jobs:
|
|||||||
- name: Run actionlint
|
- name: Run actionlint
|
||||||
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||||
|
|
||||||
|
lint-yaml:
|
||||||
|
name: Lint YAML
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install lint dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install ".[dev]"
|
||||||
|
|
||||||
|
- name: Run yamllint
|
||||||
|
run: yamllint --strict .
|
||||||
|
|
||||||
|
lint-js:
|
||||||
|
name: Lint JavaScript
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/package.json
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
working-directory: app
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run eslint
|
||||||
|
working-directory: app
|
||||||
|
run: npx eslint .
|
||||||
|
|
||||||
|
lint-shell:
|
||||||
|
name: Lint shell scripts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run shellcheck
|
||||||
|
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
|
||||||
|
|
||||||
lint-python:
|
lint-python:
|
||||||
name: Lint Python
|
name: Lint Python
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
23
.yamllint
Normal file
23
.yamllint
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
# Correctness only. No `extends: default`, so nothing but the rules below runs:
|
||||||
|
# the repository predates this linter and its cosmetic findings (indentation,
|
||||||
|
# line length, trailing spaces) would be noise nobody acts on.
|
||||||
|
#
|
||||||
|
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
|
||||||
|
# identical keys without complaining, so a duplicated entry in a translation
|
||||||
|
# catalogue silently drops a translation.
|
||||||
|
ignore: |
|
||||||
|
.git/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
app/node_modules/
|
||||||
|
app/static/vendor/
|
||||||
|
build/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
rules:
|
||||||
|
key-duplicates: enable
|
||||||
|
octal-values:
|
||||||
|
forbid-implicit-octal: true
|
||||||
|
forbid-explicit-octal: true
|
||||||
44
Makefile
44
Makefile
@@ -78,6 +78,8 @@ run-dev: env config config
|
|||||||
-p "$$PORT:$$PORT" \
|
-p "$$PORT:$$PORT" \
|
||||||
--name portfolio \
|
--name portfolio \
|
||||||
-v "$(PWD)/app/:/app" \
|
-v "$(PWD)/app/:/app" \
|
||||||
|
-e PORT="$$PORT" \
|
||||||
|
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||||
-e FLASK_APP=app.py \
|
-e FLASK_APP=app.py \
|
||||||
-e FLASK_ENV=development \
|
-e FLASK_ENV=development \
|
||||||
"$$IMAGE_NAME"
|
"$$IMAGE_NAME"
|
||||||
@@ -89,6 +91,8 @@ run-prod: env config config
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
-p "$$PORT:$$PORT" \
|
-p "$$PORT:$$PORT" \
|
||||||
--name portfolio \
|
--name portfolio \
|
||||||
|
-e PORT="$$PORT" \
|
||||||
|
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||||
"$$IMAGE_NAME"
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
@@ -132,6 +136,14 @@ install-dev:
|
|||||||
# Install runtime and developer dependencies from pyproject.toml.
|
# Install runtime and developer dependencies from pyproject.toml.
|
||||||
$(PYTHON) -m pip install -e ".[dev]"
|
$(PYTHON) -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
.PHONY: i18n
|
||||||
|
i18n: env config
|
||||||
|
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
|
||||||
|
@$(call _require_env,LIBRETRANSLATE_URL); \
|
||||||
|
$(PYTHON) utils/i18n_sync.py \
|
||||||
|
--url "$$LIBRETRANSLATE_URL" \
|
||||||
|
--api-key "$$LIBRETRANSLATE_API_KEY"
|
||||||
|
|
||||||
.PHONY: lint-actions
|
.PHONY: lint-actions
|
||||||
lint-actions:
|
lint-actions:
|
||||||
# Lint GitHub Actions workflows.
|
# Lint GitHub Actions workflows.
|
||||||
@@ -148,6 +160,21 @@ lint-docker:
|
|||||||
# Lint the Dockerfile.
|
# Lint the Dockerfile.
|
||||||
docker run --rm -i hadolint/hadolint < Dockerfile
|
docker run --rm -i hadolint/hadolint < Dockerfile
|
||||||
|
|
||||||
|
.PHONY: lint-yaml
|
||||||
|
lint-yaml: install-dev
|
||||||
|
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
|
||||||
|
$(PYTHON) -m yamllint --strict .
|
||||||
|
|
||||||
|
.PHONY: lint-js
|
||||||
|
lint-js: node-deps
|
||||||
|
# Lint the browser and Cypress JavaScript.
|
||||||
|
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
|
||||||
|
|
||||||
|
.PHONY: lint-shell
|
||||||
|
lint-shell:
|
||||||
|
# Lint the shell scripts.
|
||||||
|
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
|
||||||
|
|
||||||
.PHONY: test-lint
|
.PHONY: test-lint
|
||||||
test-lint:
|
test-lint:
|
||||||
# Run lint guardrail tests.
|
# Run lint guardrail tests.
|
||||||
@@ -169,7 +196,7 @@ test-security: install
|
|||||||
$(PYTHON) -m unittest discover -s tests/security -t .
|
$(PYTHON) -m unittest discover -s tests/security -t .
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-actions lint-python lint-docker test-lint
|
lint: lint-actions lint-python lint-yaml lint-js lint-docker lint-shell test-lint
|
||||||
# Run the full lint suite.
|
# Run the full lint suite.
|
||||||
|
|
||||||
.PHONY: security
|
.PHONY: security
|
||||||
@@ -179,9 +206,20 @@ security: install-dev test-security
|
|||||||
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
||||||
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
||||||
|
|
||||||
|
.PHONY: node-deps
|
||||||
|
node-deps:
|
||||||
|
# Install the Cypress binary and the browser vendor assets into app/.
|
||||||
|
cd app && npm install
|
||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
test-e2e:
|
test-e2e: env config node-deps
|
||||||
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
|
# Run Cypress against a locally started Flask app — no act, no runner image.
|
||||||
|
@$(call _require_env,PORT); \
|
||||||
|
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
|
||||||
|
|
||||||
|
.PHONY: test-e2e-act
|
||||||
|
test-e2e-act:
|
||||||
|
# Run the CI end-to-end job through act (stop portfolio container to free port first).
|
||||||
-docker stop portfolio 2>/dev/null || true
|
-docker stop portfolio 2>/dev/null || true
|
||||||
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||||
-docker start portfolio 2>/dev/null || true
|
-docker start portfolio 2>/dev/null || true
|
||||||
|
|||||||
78
app/eslint.config.js
Normal file
78
app/eslint.config.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Correctness only — eslint's recommended set, no stylistic rules.
|
||||||
|
*
|
||||||
|
* The browser scripts are plain <script> tags sharing one global scope: each
|
||||||
|
* file declares some functions and calls others declared elsewhere. That is why
|
||||||
|
* they are listed as globals and why no-redeclare is off — the declaring file
|
||||||
|
* would otherwise be reported for defining its own function.
|
||||||
|
*/
|
||||||
|
const js = require('@eslint/js');
|
||||||
|
const globals = require('globals');
|
||||||
|
|
||||||
|
// Vendored libraries plus the functions static/js files call across each other.
|
||||||
|
// Keep this to names that really cross a file boundary. Every superfluous
|
||||||
|
// entry is a permanent no-undef blind spot for a typo of that name.
|
||||||
|
const SHARED = {
|
||||||
|
bootstrap: 'readonly',
|
||||||
|
marked: 'readonly',
|
||||||
|
$: 'readonly',
|
||||||
|
jQuery: 'readonly',
|
||||||
|
openDynamicPopup: 'readonly',
|
||||||
|
closeAllModals: 'readonly',
|
||||||
|
isSafeUrl: 'readonly',
|
||||||
|
openIframe: 'readonly',
|
||||||
|
enterFullscreen: 'readonly',
|
||||||
|
exitFullscreen: 'readonly',
|
||||||
|
setFullWidth: 'readonly',
|
||||||
|
initFullWidthFromUrl: 'readonly',
|
||||||
|
adjustScrollContainerHeight: 'readonly',
|
||||||
|
updateCustomScrollbar: 'readonly',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
|
||||||
|
{
|
||||||
|
files: ['static/js/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: { ...globals.browser, ...SHARED },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-redeclare': 'off',
|
||||||
|
// vars: 'local' — a top-level function here is the API other files and
|
||||||
|
// the templates call, so only unused locals are a defect.
|
||||||
|
'no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ vars: 'local', args: 'none', caughtErrors: 'none' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['cypress/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.mocha,
|
||||||
|
cy: 'readonly',
|
||||||
|
Cypress: 'readonly',
|
||||||
|
expect: 'readonly',
|
||||||
|
assert: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: js.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
rules: js.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
"marked": "^4.3.0"
|
"marked": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cypress": "^14.5.1"
|
"@eslint/js": "^10.0.1",
|
||||||
|
"cypress": "^14.5.1",
|
||||||
|
"eslint": "^10.9.0",
|
||||||
|
"globals": "^17.11.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/copy-vendor.js",
|
"build": "node scripts/copy-vendor.js",
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||||
const rect = submenu.getBoundingClientRect();
|
|
||||||
const parentRect = parent.getBoundingClientRect();
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
const spaceAbove = parentRect.top;
|
const spaceAbove = parentRect.top;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ description = "A lightweight YAML-driven portfolio and landing-page generator."
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask",
|
# 3.1 introduced TRUSTED_HOSTS; an older Flask accepts the config key and
|
||||||
|
# ignores it, which would silently disable the host-forgery check.
|
||||||
|
"flask>=3.1",
|
||||||
"pyyaml",
|
"pyyaml",
|
||||||
"requests",
|
"requests",
|
||||||
]
|
]
|
||||||
@@ -19,6 +21,7 @@ dev = [
|
|||||||
"bandit",
|
"bandit",
|
||||||
"pip-audit",
|
"pip-audit",
|
||||||
"ruff",
|
"ruff",
|
||||||
|
"yamllint",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
@@ -30,6 +33,8 @@ include = ["app", "app.*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
app = [
|
app = [
|
||||||
"config.sample.yaml",
|
"config.sample.yaml",
|
||||||
|
"i18n/ui/*.yaml",
|
||||||
|
"i18n/content/*.yaml",
|
||||||
"templates/**/*.j2",
|
"templates/**/*.j2",
|
||||||
"static/css/*.css",
|
"static/css/*.css",
|
||||||
"static/js/*.js",
|
"static/js/*.js",
|
||||||
|
|||||||
73
scripts/run-e2e.sh
Executable file
73
scripts/run-e2e.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run the Cypress suite against a Flask app started by this script.
|
||||||
|
#
|
||||||
|
# Server and browser share one shell, so this works where act cannot reach the
|
||||||
|
# host network. ELECTRON_RUN_AS_NODE is dropped before invoking Cypress because
|
||||||
|
# VS Code exports it to child processes, which makes Cypress' bundled Electron
|
||||||
|
# behave as Node and reject its own launch flags.
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# PORT required, the port Flask binds and Cypress targets
|
||||||
|
# PYTHON interpreter to run app.py with (default: python3)
|
||||||
|
# E2E_LOG where Flask's output goes (default: /tmp/portfolio-e2e-flask.log)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PORT="${PORT:?PORT must be set (see env.example)}"
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
E2E_LOG="${E2E_LOG:-/tmp/portfolio-e2e-flask.log}"
|
||||||
|
FLASK_HOST=127.0.0.1
|
||||||
|
export PORT FLASK_HOST
|
||||||
|
|
||||||
|
APP_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../app" && pwd)"
|
||||||
|
BASE_URL="http://127.0.0.1:$PORT/"
|
||||||
|
|
||||||
|
# cypress.config.js defaults to localhost, which resolves to ::1 first on a
|
||||||
|
# dual-stack host — a different listener from the 127.0.0.1 one Flask binds and
|
||||||
|
# this script probes. Pin all three to the same origin.
|
||||||
|
export CYPRESS_baseUrl="$BASE_URL"
|
||||||
|
|
||||||
|
# A foreign listener would be tested instead of the working tree. The predicate
|
||||||
|
# is "something answers", deliberately without curl's -f: a foreign server that
|
||||||
|
# is still returning 5xx right now would otherwise pass the guard and then be
|
||||||
|
# picked up by the readiness probe once it recovers. --noproxy is what keeps
|
||||||
|
# that same looser predicate from matching an HTTP proxy instead of the port.
|
||||||
|
if curl -s --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
echo "ERROR: something already serves port $PORT — Cypress would test that"
|
||||||
|
echo " instead of your working tree. Stop it first ('make down' for"
|
||||||
|
echo " the container, otherwise a stray 'python app.py')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$APP_DIR" || exit 1
|
||||||
|
$PYTHON app.py > "$E2E_LOG" 2>&1 &
|
||||||
|
flask_pid=$!
|
||||||
|
trap 'kill "$flask_pid" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Waiting for $BASE_URL — follow with: tail -f $E2E_LOG"
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 120 ]; do
|
||||||
|
# Liveness first: a server answering while ours is dead means we are about
|
||||||
|
# to hand Cypress somebody else's app.
|
||||||
|
if ! kill -0 "$flask_pid" 2>/dev/null; then
|
||||||
|
echo "ERROR: Flask exited during startup"
|
||||||
|
cat "$E2E_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
echo "ERROR: app never became ready"
|
||||||
|
cat "$E2E_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
env -u ELECTRON_RUN_AS_NODE npx cypress run
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
kill "$flask_pid" 2>/dev/null || true
|
||||||
|
exit "$status"
|
||||||
Reference in New Issue
Block a user