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:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-22 01:31:10 +02:00
1 parent 575a5a87ac
commit 6fee7ca136
25 files changed
+2297 -6

No files matched your search

+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"rules": {}
}
+55 -3
View File
@@ -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]
+8
View File
@@ -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'
+15
View File
@@ -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"
}
}
+1440 -1
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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
+35
View File
@@ -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[@]}"
+39
View File
@@ -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}"
+39
View File
@@ -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}"
+42
View File
@@ -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}"
+24
View File
@@ -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"
+35
View File
@@ -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
+28
View File
@@ -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);
});
+26 -1
View File
@@ -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]()
+24
View File
@@ -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.")
+24
View File
@@ -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.")
+24
View File
@@ -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.")
+28
View File
@@ -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.")
+8
View File
@@ -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"],