mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
feat(quality): parse every shipped language and run its unit suite
Four parse checks join the `lint-*` family, each using the language's own parser rather than a reimplementation: `php -l`, `ruby -c`, `sqlfluff parse`, and stylelint with an empty rule set (it raises CssSyntaxError whatever the rules say, so the check stays syntax-only and grows no style opinion). Three unit runners join `test-unit`: node:test, PHPUnit, minitest -- the first and third ship with their runtime, so only PHPUnit adds a dependency. Provisioning is best effort and the lint script decides whether it can run. A machine without PHP must not fail the gate, but it must not look like success either: the skip names how many files went unchecked. The first version of these scripts read `git ls-files`, which inside the compose container returns nothing (git refuses the bind-mounted repo as "dubious ownership") and reported "No SQL files found." over 52 files -- green, and a lie. They use `find` now, as the surrounding lint scripts already did. sqlfluff needed two corrections before it parsed anything real. `%(name)s` is a psycopg parameter marker, not SQL, so the placeholder templater is configured rather than the files exempted; and the dialect follows each role's declared engine (`meta/services.yml`), which is postgres for six roles and mysql for svc-db-mariadb and pixelfed. Recipes stay in scripts, not in the Makefile: the targets delegate, matching every other target in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
575a5a87ac
commit
6fee7ca136
25 files changed
+2297
-6
No files matched your search
@@ -0,0 +1,11 @@
|
||||
[sqlfluff]
|
||||
# Postgres is the majority engine. A role whose meta/services.yml declares
|
||||
# `mariadb:` instead overrides this with its own .sqlfluff beside the files.
|
||||
dialect = postgres
|
||||
templater = placeholder
|
||||
|
||||
# The shipped SQL is executed through psycopg, so `%(name)s` parameter markers
|
||||
# are the driver's, not SQL. Without this the parser rejects every file that
|
||||
# takes an argument.
|
||||
[sqlfluff:templater:placeholder]
|
||||
param_style = pyformat
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rules": {}
|
||||
}
|
||||
@@ -384,15 +384,19 @@ kernel-loop-load:
|
||||
lint: install-lint
|
||||
@bash scripts/make/parallel.sh lint-action \
|
||||
lint-ansible \
|
||||
lint-css \
|
||||
lint-dockerfile \
|
||||
lint-javascript \
|
||||
lint-makefile \
|
||||
lint-markdown \
|
||||
lint-mermaid \
|
||||
lint-packages \
|
||||
lint-php \
|
||||
lint-playwright \
|
||||
lint-python \
|
||||
lint-shellcheck
|
||||
lint-ruby \
|
||||
lint-shellcheck \
|
||||
lint-sql
|
||||
|
||||
.PHONY: lint-action
|
||||
# Run the GitHub Actions lint checks.
|
||||
@@ -405,6 +409,12 @@ lint-action: install-lint
|
||||
lint-ansible: install-lint setup
|
||||
@bash scripts/lint/wrapper.sh ansible
|
||||
|
||||
.PHONY: lint-css
|
||||
# Check that every CSS file parses, via stylelint with an empty rule set.
|
||||
lint-css: install-lint
|
||||
@bash scripts/install/wrapper.sh css
|
||||
@bash scripts/lint/wrapper.sh css
|
||||
|
||||
.PHONY: lint-dockerfile
|
||||
# Run hadolint over the root Dockerfile.
|
||||
lint-dockerfile: install-lint
|
||||
@@ -438,6 +448,13 @@ lint-packages: install-lint
|
||||
@bash scripts/install/wrapper.sh packages
|
||||
@bash scripts/lint/wrapper.sh packages
|
||||
|
||||
.PHONY: lint-php
|
||||
# Check that every PHP file parses, via `php -l`.
|
||||
# Note: provisions the PHP CLI explicitly; an absent interpreter is skipped.
|
||||
lint-php: install-lint
|
||||
@bash scripts/install/wrapper.sh php
|
||||
@bash scripts/lint/wrapper.sh php
|
||||
|
||||
.PHONY: lint-playwright
|
||||
# Verify every role's Playwright spec parses + resolves its helpers.
|
||||
# Note: stages the spec like test-e2e-playwright does and runs `npx playwright test --list`.
|
||||
@@ -449,11 +466,25 @@ lint-playwright: install-lint
|
||||
lint-python: install-lint
|
||||
@bash scripts/lint/wrapper.sh python
|
||||
|
||||
.PHONY: lint-ruby
|
||||
# Check that every Ruby file parses, via `ruby -c`.
|
||||
# Note: provisions the Ruby CLI explicitly; an absent interpreter is skipped.
|
||||
lint-ruby: install-lint
|
||||
@bash scripts/install/wrapper.sh ruby
|
||||
@bash scripts/lint/wrapper.sh ruby
|
||||
|
||||
.PHONY: lint-shellcheck
|
||||
# Run shellcheck lint checks.
|
||||
lint-shellcheck: install-lint
|
||||
@bash scripts/lint/wrapper.sh shellcheck
|
||||
|
||||
.PHONY: lint-sql
|
||||
# Check that every SQL file parses, via `sqlfluff parse`.
|
||||
# Note: dialect comes from the nearest .sqlfluff; absent tooling is skipped.
|
||||
lint-sql: install-lint
|
||||
@bash scripts/install/wrapper.sh sql
|
||||
@bash scripts/lint/wrapper.sh sql
|
||||
|
||||
.PHONY: meta-list
|
||||
# Print the repository role list.
|
||||
meta-list:
|
||||
@@ -784,12 +815,33 @@ test-signed:
|
||||
echo "✅ HEAD commit signature status: $$status"
|
||||
|
||||
.PHONY: test-unit
|
||||
# Run the unit test suite.
|
||||
test-unit: install
|
||||
# Run the unit test suite (every language).
|
||||
test-unit: test-unit-python test-unit-javascript test-unit-php test-unit-ruby
|
||||
|
||||
.PHONY: test-unit-javascript
|
||||
# Run the JavaScript unit test suite (node:test, no extra dependency).
|
||||
test-unit-javascript:
|
||||
@bash scripts/tests/unit/javascript.sh
|
||||
|
||||
.PHONY: test-unit-php
|
||||
# Run the PHP unit test suite (PHPUnit).
|
||||
# Note: skips loudly while the suite is empty or PHP is absent; see scripts/tests/unit/php.sh.
|
||||
test-unit-php:
|
||||
@bash scripts/tests/unit/php.sh
|
||||
|
||||
.PHONY: test-unit-python
|
||||
# Run the Python unit test suite.
|
||||
test-unit-python: install
|
||||
@INFINITO_TEST_TYPE="unit" \
|
||||
INFINITO_COMPILE=0 \
|
||||
bash scripts/tests/code/wrapper.sh
|
||||
|
||||
.PHONY: test-unit-ruby
|
||||
# Run the Ruby unit test suite (minitest, stdlib).
|
||||
# Note: skips loudly while the suite is empty or Ruby is absent; see scripts/tests/unit/ruby.sh.
|
||||
test-unit-ruby:
|
||||
@bash scripts/tests/unit/ruby.sh
|
||||
|
||||
.PHONY: worktree-down
|
||||
# Stop a branch worktree's stack, release the checkout and free its slot.
|
||||
# Usage: make worktree-down branch=<name> [base=<dir>] [force=true]
|
||||
|
||||
@@ -44,6 +44,7 @@ alias i8kelo='m kernel-loop-load'
|
||||
alias i8la='m alias'
|
||||
alias i8li='m lint'
|
||||
alias i8lica='m lint-action'
|
||||
alias i8lics='m lint-css'
|
||||
alias i8lido='m lint-dockerfile'
|
||||
alias i8lija='m lint-javascript'
|
||||
alias i8lima='m lint-makefile'
|
||||
@@ -52,8 +53,11 @@ alias i8lime='m lint-mermaid'
|
||||
alias i8lina='m lint-ansible'
|
||||
alias i8lipa='m lint-packages'
|
||||
alias i8lipa2='m lint-playwright'
|
||||
alias i8liph='m lint-php'
|
||||
alias i8lipo='m lint-python'
|
||||
alias i8liru='m lint-ruby'
|
||||
alias i8lise='m lint-shellcheck'
|
||||
alias i8lisq='m lint-sql'
|
||||
alias i8maal='i8source && python3 -m cli.meta.ci.query --modes auto --matrix' # complexity matrix of every deployable row, in CI query order
|
||||
alias i8maco='i8source && python3 -m cli.meta.ci.query --modes compose --matrix' # complexity matrix in compose query order
|
||||
alias i8maho='i8source && python3 -m cli.meta.ci.query --modes host --matrix' # complexity matrix in host query order
|
||||
@@ -138,6 +142,10 @@ alias i8tema='m test-main-merged'
|
||||
alias i8temesi='m test-merge-signed'
|
||||
alias i8teni='m test-integration'
|
||||
alias i8tenu='m test-unit'
|
||||
alias i8tenuja='m test-unit-javascript'
|
||||
alias i8tenuph='m test-unit-php'
|
||||
alias i8tenupo='m test-unit-python'
|
||||
alias i8tenuru='m test-unit-ruby'
|
||||
alias i8tepe='m test-performance'
|
||||
alias i8tesi='m test-signed'
|
||||
alias i8texe='m test-external'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "infinito-nexus/core",
|
||||
"description": "PHPUnit dependencies for the PHP shipped in roles/*/files/php/.",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit"
|
||||
}
|
||||
}
|
||||
Generated
+1440
-1
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -12,7 +12,8 @@
|
||||
"@playwright/test": "^1.62.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-playwright": "^2.10.5",
|
||||
"globals": "^17.9.0"
|
||||
"globals": "^17.9.0",
|
||||
"stylelint": "^16.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "^5.0.9"
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
failOnWarning="true"
|
||||
failOnRisky="true"
|
||||
beStrictAboutOutputDuringTests="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/unit/php</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">roles</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,6 @@
|
||||
[sqlfluff]
|
||||
dialect = mysql
|
||||
templater = placeholder
|
||||
|
||||
[sqlfluff:templater:placeholder]
|
||||
param_style = pyformat
|
||||
@@ -0,0 +1,6 @@
|
||||
[sqlfluff]
|
||||
dialect = mysql
|
||||
templater = placeholder
|
||||
|
||||
[sqlfluff:templater:placeholder]
|
||||
param_style = pyformat
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# stylelint reports a CssSyntaxError whatever the rule set says, so the empty
|
||||
# config in .stylelintrc.json is enough to make this a parse check and keeps it
|
||||
# from turning into a style opinion nobody signed up for.
|
||||
#
|
||||
# File discovery uses find, not git ls-files: inside the compose container git
|
||||
# refuses the bind-mounted repo with "detected dubious ownership", which makes
|
||||
# git ls-files print nothing and turns this lint into a silent no-op.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
mapfile -t css_files < <(
|
||||
find . -type f -name '*.css' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' |
|
||||
sed 's|^\./||' | sort
|
||||
)
|
||||
if [[ "${#css_files[@]}" -eq 0 ]]; then
|
||||
printf 'No CSS files found.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -d node_modules/stylelint ]]; then
|
||||
printf 'SKIP: stylelint is not installed; %d file(s) left unchecked.\n' "${#css_files[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
npx --no-install stylelint "${css_files[@]}"
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# File discovery uses find, not git ls-files: inside the compose container git
|
||||
# refuses the bind-mounted repo with "detected dubious ownership", which makes
|
||||
# git ls-files print nothing and turns this lint into a silent no-op.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
mapfile -t php_files < <(
|
||||
find . -type f -name '*.php' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './.git/*' |
|
||||
sed 's|^\./||' | sort
|
||||
)
|
||||
if [[ "${#php_files[@]}" -eq 0 ]]; then
|
||||
printf 'No PHP files found.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v php >/dev/null 2>&1; then
|
||||
printf 'SKIP: php is not installed; %d file(s) left unchecked.\n' "${#php_files[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
failed=0
|
||||
for file in "${php_files[@]}"; do
|
||||
if ! php -l "${file}"; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "${failed}"
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# File discovery uses find, not git ls-files: inside the compose container git
|
||||
# refuses the bind-mounted repo with "detected dubious ownership", which makes
|
||||
# git ls-files print nothing and turns this lint into a silent no-op.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
mapfile -t ruby_files < <(
|
||||
find . -type f -name '*.rb' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' |
|
||||
sed 's|^\./||' | sort
|
||||
)
|
||||
if [[ "${#ruby_files[@]}" -eq 0 ]]; then
|
||||
printf 'No Ruby files found.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v ruby >/dev/null 2>&1; then
|
||||
printf 'SKIP: ruby is not installed; %d file(s) left unchecked.\n' "${#ruby_files[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
failed=0
|
||||
for file in "${ruby_files[@]}"; do
|
||||
if ! ruby -c "${file}" >/dev/null; then
|
||||
printf '%s: does not parse\n' "${file}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "${failed}"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Dialect comes from the nearest .sqlfluff: the repo root sets postgres, and a
|
||||
# directory whose SQL targets another engine overrides it beside the files.
|
||||
#
|
||||
# File discovery uses find, not git ls-files: inside the compose container git
|
||||
# refuses the bind-mounted repo with "detected dubious ownership", which makes
|
||||
# git ls-files print nothing and turns this lint into a silent no-op.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
mapfile -t sql_files < <(
|
||||
find . -type f -name '*.sql' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' |
|
||||
sed 's|^\./||' | sort
|
||||
)
|
||||
if [[ "${#sql_files[@]}" -eq 0 ]]; then
|
||||
printf 'No SQL files found.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v sqlfluff >/dev/null 2>&1; then
|
||||
printf 'SKIP: sqlfluff is not installed; %d file(s) left unchecked.\n' "${#sql_files[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
failed=0
|
||||
for file in "${sql_files[@]}"; do
|
||||
if ! sqlfluff parse "${file}" >/dev/null; then
|
||||
printf '%s: does not parse\n' "${file}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "${failed}"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Runs the JavaScript unit suite through node:test, Node's built-in runner.
|
||||
#
|
||||
# The glob is quoted so node expands it, not the shell: passing the bare
|
||||
# directory makes the runner resolve test files as module specifiers and fail
|
||||
# with MODULE_NOT_FOUND.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
suite="tests/unit/javascript"
|
||||
|
||||
if [[ -z "$(find "${suite}" -name '*.test.js' 2>/dev/null)" ]]; then
|
||||
printf 'SKIP test-unit-javascript: no JavaScript unit tests in %s\n' "${suite}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec node --test "${suite}/**/*.test.js"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Runs the PHP unit suite through PHPUnit.
|
||||
#
|
||||
# Every shipped PHP file carries a mirrored-unit-test exemption today, so the
|
||||
# suite is empty and this skips rather than letting PHPUnit fail on "no tests
|
||||
# executed". An absent interpreter or vendor tree skips loudly for the same
|
||||
# reason: a machine without PHP must not fail the whole gate.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
suite="tests/unit/php"
|
||||
|
||||
if [[ -z "$(find "${suite}" -name '*Test.php' 2>/dev/null)" ]]; then
|
||||
printf 'SKIP test-unit-php: no PHP unit tests in %s (see lint mirrored-unit-test)\n' "${suite}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v php >/dev/null 2>&1; then
|
||||
printf 'SKIP test-unit-php: php is not installed on this machine\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -x vendor/bin/phpunit ]]; then
|
||||
printf "SKIP test-unit-php: run 'composer install' first\n"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec vendor/bin/phpunit
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Runs the Ruby unit suite through minitest, which ships with Ruby itself.
|
||||
#
|
||||
# minitest/autorun has no directory runner, so the suite files are required
|
||||
# explicitly and minitest's at_exit hook runs them.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
suite="tests/unit/ruby"
|
||||
|
||||
if [[ -z "$(find "${suite}" -name '*_test.rb' 2>/dev/null)" ]]; then
|
||||
printf 'SKIP test-unit-ruby: no Ruby unit tests in %s (see lint mirrored-unit-test)\n' "${suite}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v ruby >/dev/null 2>&1; then
|
||||
printf 'SKIP test-unit-ruby: ruby is not installed on this machine\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec ruby -I"${suite}" -e 'Dir.glob("'"${suite}"'/**/*_test.rb").each { |f| require File.expand_path(f) }'
|
||||
@@ -0,0 +1,140 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const SOURCE = path.join(
|
||||
__dirname,
|
||||
"..", "..", "..", "..", "..", "..",
|
||||
"roles", "sys-front-inj-logout", "files", "javascript", "logout.js",
|
||||
);
|
||||
|
||||
const LOGOUT_URL = "https://logout.example.org/";
|
||||
|
||||
function element(tagName, attributes = {}, text = "") {
|
||||
const el = {
|
||||
tagName,
|
||||
dataset: {},
|
||||
style: {},
|
||||
listeners: [],
|
||||
innerText: text,
|
||||
attributes: [],
|
||||
getAttribute(name) {
|
||||
const found = el.attributes.find((a) => a.name === name);
|
||||
return found ? found.value : null;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
const found = el.attributes.find((a) => a.name === name);
|
||||
if (found) {
|
||||
found.value = value;
|
||||
return;
|
||||
}
|
||||
el.attributes.push({ name, value });
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return el.attributes.some((a) => a.name === name);
|
||||
},
|
||||
addEventListener(type, handler, options) {
|
||||
el.listeners.push({ type, handler, options });
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
el.id = el.getAttribute("id") || "";
|
||||
el.className = el.getAttribute("class") || "";
|
||||
return el;
|
||||
}
|
||||
|
||||
function patch(elements) {
|
||||
const sandbox = {
|
||||
console: { debug() {} },
|
||||
location: { href: "" },
|
||||
Element: class Element {},
|
||||
MutationObserver: class MutationObserver {
|
||||
observe() {}
|
||||
},
|
||||
document: {
|
||||
body: {},
|
||||
querySelectorAll: () => elements,
|
||||
},
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fs.readFileSync(SOURCE, "utf8"), sandbox);
|
||||
sandbox.initLogoutPatch(LOGOUT_URL, false);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test("an anchor whose text reads 'Log out' is redirected", () => {
|
||||
const link = element("A", { href: "/account/exit" }, "Log out");
|
||||
patch([link]);
|
||||
|
||||
assert.equal(link.getAttribute("href"), LOGOUT_URL);
|
||||
assert.equal(link.dataset._logoutHandled, "true");
|
||||
assert.equal(link.listeners.length, 1);
|
||||
assert.equal(link.listeners[0].options.capture, true);
|
||||
});
|
||||
|
||||
test("a click on a patched element is intercepted, not followed", () => {
|
||||
const link = element("A", { id: "logout-link" }, "Bye");
|
||||
const sandbox = patch([link]);
|
||||
|
||||
let prevented = false;
|
||||
link.listeners[0].handler({ preventDefault: () => { prevented = true; } });
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(sandbox.window.location.href, LOGOUT_URL);
|
||||
});
|
||||
|
||||
test("an href pointing at /logout is NOT enough to match", () => {
|
||||
const link = element("A", { href: "/logout" }, "My profile");
|
||||
patch([link]);
|
||||
|
||||
assert.equal(link.getAttribute("href"), "/logout");
|
||||
assert.equal(link.dataset._logoutHandled, undefined);
|
||||
});
|
||||
|
||||
test("a non-interactive tag is never patched, whatever it says", () => {
|
||||
const div = element("DIV", { id: "logout" }, "Log out");
|
||||
patch([div]);
|
||||
|
||||
assert.equal(div.dataset._logoutHandled, undefined);
|
||||
assert.equal(div.listeners.length, 0);
|
||||
});
|
||||
|
||||
test("a button carrying formaction has that formaction rewritten", () => {
|
||||
const button = element("BUTTON", { formaction: "/session/end", title: "Logout" });
|
||||
patch([button]);
|
||||
|
||||
assert.equal(button.getAttribute("formaction"), LOGOUT_URL);
|
||||
});
|
||||
|
||||
test("a data-* attribute value only counts on data-/aria- names", () => {
|
||||
const matched = element("BUTTON", { "data-action": "logout" }, "");
|
||||
const ignored = element("BUTTON", { "value": "logout" }, "");
|
||||
patch([matched, ignored]);
|
||||
|
||||
assert.equal(matched.dataset._logoutHandled, "true");
|
||||
assert.equal(ignored.dataset._logoutHandled, undefined);
|
||||
});
|
||||
|
||||
test("an element already handled is not bound a second time", () => {
|
||||
const link = element("A", { id: "logout" }, "");
|
||||
const sandbox = patch([link]);
|
||||
sandbox.initLogoutPatch(LOGOUT_URL, false);
|
||||
|
||||
assert.equal(link.listeners.length, 1);
|
||||
});
|
||||
|
||||
test("a text block over 1000 characters is ignored as a match source", () => {
|
||||
const link = element("A", {}, `${"x".repeat(1000)} log out`);
|
||||
patch([link]);
|
||||
|
||||
assert.equal(link.dataset._logoutHandled, undefined);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "../../../../../..");
|
||||
const PANEL = path.join(
|
||||
PROJECT_ROOT,
|
||||
"roles/web-app-keycloak/files/javascript/logout-panel.js",
|
||||
);
|
||||
const CATALOGUE = path.join(
|
||||
PROJECT_ROOT,
|
||||
"roles/web-app-keycloak/files/logout_i18n.yml",
|
||||
);
|
||||
const ORIGIN = "https://logout.example.test";
|
||||
const DOMAINS = ["https://shop.example.test", "https://cloud.example.test"];
|
||||
|
||||
/**
|
||||
* Read the catalogue without a YAML dependency.
|
||||
*
|
||||
* The file is generated, flat and two levels deep, so a line reader is enough
|
||||
* and keeps this suite free of a parser it would otherwise need for one file.
|
||||
*/
|
||||
function readCatalogue() {
|
||||
const out = {};
|
||||
let current = null;
|
||||
for (const line of fs.readFileSync(CATALOGUE, "utf8").split("\n")) {
|
||||
const top = line.match(/^([a-z]{2}):\s*$/);
|
||||
if (top) {
|
||||
current = {};
|
||||
out[top[1]] = current;
|
||||
continue;
|
||||
}
|
||||
const entry = line.match(/^ {2}([a-z_]+):\s*(.*)$/);
|
||||
if (entry && current) {
|
||||
let value = entry[2].trim();
|
||||
if (
|
||||
(value.startsWith("'") && value.endsWith("'")) ||
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
) {
|
||||
value = value.slice(1, -1).replace(/''/g, "'");
|
||||
}
|
||||
current[entry[1]] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function element() {
|
||||
const node = {
|
||||
children: [],
|
||||
style: { cssText: "" },
|
||||
attrs: {},
|
||||
id: "",
|
||||
title: "",
|
||||
href: "",
|
||||
setAttribute(key, value) {
|
||||
this.attrs[key] = value;
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
let text = "";
|
||||
Object.defineProperty(node, "textContent", {
|
||||
get: () => text,
|
||||
set(value) {
|
||||
text = value;
|
||||
node.children.length = 0;
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Load the panel against a stubbed page and return handles for assertions. */
|
||||
function mount({ lang = "en", hostname = "auth.example.test", path: pathname = "/realms/x/protocol/openid-connect/logout" } = {}) {
|
||||
const body = element();
|
||||
const listeners = {};
|
||||
const timers = {};
|
||||
let next = 0;
|
||||
|
||||
global.window = {
|
||||
__INFINITO_LOGOUT__: { origin: ORIGIN, i18n: readCatalogue() },
|
||||
location: { pathname, hostname },
|
||||
addEventListener(type, fn) {
|
||||
listeners[type] = listeners[type] || [];
|
||||
if (!listeners[type].includes(fn)) listeners[type].push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
listeners[type] = (listeners[type] || []).filter((f) => f !== fn);
|
||||
},
|
||||
};
|
||||
global.document = {
|
||||
readyState: "complete",
|
||||
createElement: element,
|
||||
querySelector: () => null,
|
||||
getElementById: () => null,
|
||||
documentElement: { lang },
|
||||
body,
|
||||
addEventListener() {},
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { language: "en" },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
global.setTimeout = (fn, ms) => {
|
||||
next += 1;
|
||||
timers[next] = { fn, ms };
|
||||
return next;
|
||||
};
|
||||
global.clearTimeout = (id) => {
|
||||
if (id && timers[id]) timers[id].cleared = true;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(fs.readFileSync(PANEL, "utf8"));
|
||||
|
||||
const post = (payload) =>
|
||||
(listeners.message || []).forEach((fn) =>
|
||||
fn({ origin: ORIGIN, data: { source: "universal-logout", ...payload } }),
|
||||
);
|
||||
const panel = () => body.children[0];
|
||||
return {
|
||||
post,
|
||||
panel,
|
||||
status: () => panel().children[0].textContent,
|
||||
hint: () => panel().children[1].textContent,
|
||||
counter: () => panel().children[2].textContent,
|
||||
rows: () => panel().children[3].children,
|
||||
armed: () => (listeners.beforeunload || []).length,
|
||||
live: (ms) =>
|
||||
Object.values(timers).filter((t) => t.ms === ms && !t.cleared),
|
||||
};
|
||||
}
|
||||
|
||||
test("the catalogue reader sees every language", () => {
|
||||
const catalogue = readCatalogue();
|
||||
assert.equal(Object.keys(catalogue).length, 30);
|
||||
assert.equal(catalogue.de.dir, "ltr");
|
||||
assert.equal(catalogue.ar.dir, "rtl");
|
||||
});
|
||||
|
||||
test("a clean sweep ends by telling the visitor they may leave", () => {
|
||||
const page = mount();
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
assert.match(page.status(), /Signing you out/);
|
||||
assert.equal(page.armed(), 1);
|
||||
|
||||
page.post({ type: "host", host: DOMAINS[0], ok: true });
|
||||
page.post({ type: "host", host: DOMAINS[1], ok: true });
|
||||
page.post({ type: "done", total: 2, failed: 0 });
|
||||
|
||||
assert.match(page.status(), /Signed out everywhere/);
|
||||
assert.match(page.hint(), /You can close this page now/);
|
||||
assert.equal(page.counter(), "2 of 2 services signed out");
|
||||
assert.equal(page.armed(), 0, "the guard is released once the sweep is done");
|
||||
});
|
||||
|
||||
test("a failing host keeps a way out on screen", () => {
|
||||
const page = mount();
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
page.post({ type: "host", host: DOMAINS[1], ok: false });
|
||||
|
||||
const right = page.rows()[1].children[1];
|
||||
assert.match(right.children[0].textContent, /failed/);
|
||||
assert.equal(right.children[1].href, `${DOMAINS[1]}/logout?manual=1`);
|
||||
});
|
||||
|
||||
test("the panel speaks the language Keycloak chose", () => {
|
||||
const page = mount({ lang: "de" });
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
assert.match(page.status(), /Sie werden von Ihren Diensten abgemeldet/);
|
||||
|
||||
page.post({ type: "host", host: DOMAINS[0], ok: true });
|
||||
assert.equal(page.counter(), "1 von 2 Diensten abgemeldet");
|
||||
});
|
||||
|
||||
test("right-to-left languages flip the panel", () => {
|
||||
const page = mount({ lang: "ar" });
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
assert.equal(page.panel().attrs.dir, "rtl");
|
||||
});
|
||||
|
||||
test("an unknown language falls back to English", () => {
|
||||
const page = mount({ lang: "klingon" });
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
assert.match(page.status(), /Signing you out/);
|
||||
});
|
||||
|
||||
test("onion sessions are told to expect a longer wait", () => {
|
||||
const page = mount({ hostname: "abcdefghij.onion" });
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
assert.match(page.hint(), /Over Tor this can take a while/);
|
||||
});
|
||||
|
||||
test("a sweep that stalls says so instead of spinning forever", () => {
|
||||
const page = mount();
|
||||
page.post({ type: "start", domains: DOMAINS });
|
||||
page.post({ type: "host", host: DOMAINS[0], ok: true });
|
||||
|
||||
const [sweep] = page.live(60000);
|
||||
assert.ok(sweep, "the stall timer is armed on start");
|
||||
sweep.fn();
|
||||
|
||||
assert.match(page.status(), /Could not confirm every sign-out/);
|
||||
assert.doesNotMatch(page.status(), /Signed out everywhere/);
|
||||
assert.equal(page.armed(), 0, "the guard fails open");
|
||||
});
|
||||
|
||||
test("the panel stays off every other Keycloak page", () => {
|
||||
const page = mount({ path: "/realms/x/account" });
|
||||
assert.equal(page.armed(), 0);
|
||||
});
|
||||
@@ -23,10 +23,14 @@ from utils.install.lint import (
|
||||
mbake,
|
||||
mermaid_cli,
|
||||
packages,
|
||||
php,
|
||||
playwright,
|
||||
ruby,
|
||||
ruff,
|
||||
shellcheck,
|
||||
shfmt,
|
||||
sqlfluff,
|
||||
stylelint,
|
||||
)
|
||||
from utils.install.lint.ansible import collections as ansible_collections
|
||||
from utils.install.lint.ansible import commands as ansible_commands
|
||||
@@ -101,6 +105,22 @@ def _install_packages_tools() -> None:
|
||||
packages.ensure()
|
||||
|
||||
|
||||
def _install_php_tools() -> None:
|
||||
php.ensure()
|
||||
|
||||
|
||||
def _install_ruby_tools() -> None:
|
||||
ruby.ensure()
|
||||
|
||||
|
||||
def _install_sql_tools() -> None:
|
||||
sqlfluff.ensure()
|
||||
|
||||
|
||||
def _install_css_tools() -> None:
|
||||
stylelint.ensure()
|
||||
|
||||
|
||||
_GROUP_FN_NAMES = {
|
||||
"action": "_install_action_tools",
|
||||
"ansible": "_install_ansible_tools",
|
||||
@@ -113,6 +133,10 @@ _GROUP_FN_NAMES = {
|
||||
"javascript": "_install_javascript_tools",
|
||||
"playwright": "_install_playwright_tools",
|
||||
"packages": "_install_packages_tools",
|
||||
"php": "_install_php_tools",
|
||||
"ruby": "_install_ruby_tools",
|
||||
"sql": "_install_sql_tools",
|
||||
"css": "_install_css_tools",
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +184,8 @@ def _dispatch(group: str) -> None:
|
||||
if fn_name is None:
|
||||
raise RuntimeError(
|
||||
"Usage: python -m utils.install.lint "
|
||||
"[all|action|ansible|python|shellcheck|dockerfile|markdown|mermaid|makefile|javascript|playwright|packages]..."
|
||||
"[all|action|ansible|python|shellcheck|dockerfile|markdown|mermaid|makefile|"
|
||||
"javascript|playwright|packages|php|ruby|sql|css]..."
|
||||
)
|
||||
globals()[fn_name]()
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Install the PHP CLI via system package manager (parser for lint-php).
|
||||
|
||||
Provisioning is best effort: a host without PHP, or one that cannot install it,
|
||||
must not fail the whole gate. scripts/lint/php.sh skips loudly instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from utils.install.primitives import log, warn, which
|
||||
from utils.install.system_pkg import install_command_via_pkg
|
||||
|
||||
|
||||
def ensure() -> None:
|
||||
if which("php"):
|
||||
return
|
||||
|
||||
log("Missing command 'php'. Attempting system package installation.")
|
||||
try:
|
||||
install_command_via_pkg("php")
|
||||
except RuntimeError as exc:
|
||||
warn(f"[install-lint] php could not be installed: {exc}")
|
||||
|
||||
if not which("php"):
|
||||
warn("[install-lint] php is unavailable; lint-php will skip.")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Install the Ruby CLI via system package manager (parser for lint-ruby).
|
||||
|
||||
Provisioning is best effort: a host without Ruby, or one that cannot install it,
|
||||
must not fail the whole gate. scripts/lint/ruby.sh skips loudly instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from utils.install.primitives import log, warn, which
|
||||
from utils.install.system_pkg import install_command_via_pkg
|
||||
|
||||
|
||||
def ensure() -> None:
|
||||
if which("ruby"):
|
||||
return
|
||||
|
||||
log("Missing command 'ruby'. Attempting system package installation.")
|
||||
try:
|
||||
install_command_via_pkg("ruby")
|
||||
except RuntimeError as exc:
|
||||
warn(f"[install-lint] ruby could not be installed: {exc}")
|
||||
|
||||
if not which("ruby"):
|
||||
warn("[install-lint] ruby is unavailable; lint-ruby will skip.")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Install sqlfluff via pip (parser for lint-sql).
|
||||
|
||||
Provisioning is best effort: a host that cannot reach PyPI must not fail the
|
||||
whole gate. scripts/lint/sql.sh skips loudly instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from utils.install.pip import install_pip_pkg
|
||||
from utils.install.primitives import log, warn, which
|
||||
|
||||
|
||||
def ensure() -> None:
|
||||
if which("sqlfluff"):
|
||||
return
|
||||
|
||||
log("Missing command 'sqlfluff'. Installing via pip.")
|
||||
try:
|
||||
install_pip_pkg("sqlfluff")
|
||||
except (RuntimeError, OSError) as exc:
|
||||
warn(f"[install-lint] sqlfluff could not be installed: {exc}")
|
||||
|
||||
if not which("sqlfluff"):
|
||||
warn("[install-lint] sqlfluff is unavailable; lint-sql will skip.")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Install stylelint (locally in repo root via npm; parser for lint-css).
|
||||
|
||||
Provisioning is best effort: a host that cannot reach the npm registry must not
|
||||
fail the whole gate. scripts/lint/css.sh skips loudly instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from utils.cache import PROJECT_ROOT
|
||||
from utils.install.npm import npm_install_local_in_repo
|
||||
from utils.install.primitives import log, warn
|
||||
|
||||
|
||||
def ensure() -> None:
|
||||
repo_root = Path(PROJECT_ROOT)
|
||||
if (repo_root / "node_modules" / "stylelint").is_dir():
|
||||
return
|
||||
|
||||
log("Missing local 'stylelint'. Installing via npm (in repo root).")
|
||||
try:
|
||||
npm_install_local_in_repo(str(repo_root))
|
||||
except (RuntimeError, OSError) as exc:
|
||||
warn(f"[install-lint] stylelint could not be installed: {exc}")
|
||||
|
||||
if not (repo_root / "node_modules" / "stylelint").is_dir():
|
||||
warn("[install-lint] stylelint is unavailable; lint-css will skip.")
|
||||
@@ -102,6 +102,14 @@ _COMMAND_PACKAGES: dict[str, dict[str, list[str]]] = {
|
||||
"shfmt": {m: ["shfmt"] for m in _SUPPORTED},
|
||||
"shellcheck": {m: ["shellcheck"] for m in _SUPPORTED},
|
||||
"unzip": {m: ["unzip"] for m in _SUPPORTED},
|
||||
"php": {
|
||||
"pacman": ["php"],
|
||||
"apt-get": ["php-cli", "php"],
|
||||
"dnf": ["php-cli", "php"],
|
||||
"yum": ["php-cli", "php"],
|
||||
"brew": ["php"],
|
||||
},
|
||||
"ruby": {m: ["ruby"] for m in _SUPPORTED},
|
||||
"npm": {
|
||||
"pacman": ["npm", "nodejs"],
|
||||
"apt-get": ["npm", "nodejs"],
|
||||
|
||||
Reference in new issue
Block a user