fix(test-e2e-playwright): give every Playwright timeout one source

The config held literals for values vars/main.yml already owned, and the two
drifted without a sound. actionTimeout carried `|| 30_000` for its whole life
while nothing ever set PLAYWRIGHT_ACTION_TIMEOUT, so every run used the
unscaled 30 s. expect had no entry at all, leaving Playwright's built-in 5 s to
govern every assertion over Tor while its neighbours were scaled by the onion
multiplier. That is what killed the second Nextcloud login attempt: an
expect(usernameField).toBeVisible() that gave up after 5 s inside a 150 s
budget.

vars/main.yml now owns the values in TEST_E2E_PLAYWRIGHT_CONFIG_TIMEOUTS, the
harness renders that map into the staged .env both the deploy and
scripts/tests/e2e/rerun-spec.sh already pass, and the config demands each name
through requiredTimeout() instead of inventing a fallback. The two `-e` flags
that fed the old pair are gone with it.

scripts/lint/playwright.sh stages the same config and runs `playwright test
--list`, which loads it, but built its stub env only from each role's
playwright.env.j2 and no such template carries a timeout. Without this it would
have aborted all 87 specs on a missing variable. It now takes the names from
the same map; the value is a placeholder because --list consumes none of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-12 12:04:27 +02:00
1 parent c4e9b46519
commit 04d4d31335
5 files changed
+127 -8

No files matched your search

@@ -1,5 +1,6 @@
const { defineConfig } = require("@playwright/test");
// nocheck: env-default a role may export its own base variable and no APP_BASE_URL
const baseURL = process.env.APP_BASE_URL || "http://127.0.0.1";
const keepAll = (process.env.INFINITO_PLAYWRIGHT_KEEP || "").toLowerCase() === "true";
@@ -21,12 +22,30 @@ function onionSecureOrigins() {
const onionSecure = onionSecureOrigins();
const globalTimeout = parseInt(process.env.INFINITO_PLAYWRIGHT_GLOBAL_TIMEOUT_MS || "0", 10);
/**
* The timeout the harness renders into the staged .env under this name.
*
* Args:
* name: the PLAYWRIGHT_*_TIMEOUT variable to read.
*/
function requiredTimeout(name) {
const value = Number(process.env[name]);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(
`${name} is missing from the environment. The harness writes every ` +
"timeout into the staged .env, so this run was started outside it."
);
}
return value;
}
const globalTimeout = parseInt(process.env.INFINITO_PLAYWRIGHT_GLOBAL_TIMEOUT_MS || 0, 10);
module.exports = defineConfig({
testDir: "./tests",
testMatch: "**/*.@(spec|test).js",
timeout: Number(process.env.PLAYWRIGHT_TEST_TIMEOUT) || 300_000,
timeout: requiredTimeout("PLAYWRIGHT_TEST_TIMEOUT"),
expect: { timeout: requiredTimeout("PLAYWRIGHT_EXPECT_TIMEOUT") },
...(globalTimeout > 0 ? { globalTimeout } : {}),
retries: 2,
workers: Number(process.env.PLAYWRIGHT_WORKERS) || 1,
@@ -52,8 +71,8 @@ module.exports = defineConfig({
: undefined,
// Fail fast instead of hanging until the per-test timeout when a target is
// unreachable (e.g. an onion service that is not yet published).
navigationTimeout: Number(process.env.PLAYWRIGHT_NAVIGATION_TIMEOUT) || 60_000,
actionTimeout: Number(process.env.PLAYWRIGHT_ACTION_TIMEOUT) || 30_000,
navigationTimeout: requiredTimeout("PLAYWRIGHT_NAVIGATION_TIMEOUT"),
actionTimeout: requiredTimeout("PLAYWRIGHT_ACTION_TIMEOUT"),
trace: keepAll ? "on" : "retain-on-failure",
screenshot: keepAll ? "on" : "only-on-failure",
video: keepAll ? "on" : "retain-on-failure"
@@ -163,7 +163,11 @@
ansible.builtin.copy:
dest: "{{ _playwright_stage_dir }}/.env"
mode: "0644"
content: "{{ lookup('template', _playwright_env_template) }}"
content: |
{{ lookup('template', _playwright_env_template) }}
{% for name, value in TEST_E2E_PLAYWRIGHT_CONFIG_TIMEOUTS.items() %}
{{ name }}={{ value }}
{% endfor %}
vars:
application_id: "{{ application_id }}"
domains: "{{ lookup('domains') }}"
@@ -232,10 +236,8 @@
+ ['-e', 'PLAYWRIGHT_PROXY=' ~ TEST_E2E_PLAYWRIGHT_PROXY]
+ ['-e', 'PLAYWRIGHT_WORKERS=' ~ test_e2e_playwright_workers]
+ ['-e', 'PLAYWRIGHT_FULLY_PARALLEL=' ~ TEST_E2E_PLAYWRIGHT_FULLY_PARALLEL]
+ ['-e', 'PLAYWRIGHT_TEST_TIMEOUT=' ~ (TEST_E2E_PLAYWRIGHT_ONION_TEST_TIMEOUT if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else test_e2e_playwright_test_timeout)]
+ ['-e', 'PLAYWRIGHT_TIMEOUT_FACTOR=' ~ TIMEOUT_FACTOR]
+ (['-e', 'PLAYWRIGHT_ONION_TIMEOUT_MULTIPLIER=' ~ TEST_E2E_PLAYWRIGHT_ONION_TIMEOUT_MULTIPLIER] if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else [])
+ ['-e', 'PLAYWRIGHT_NAVIGATION_TIMEOUT=' ~ (TEST_E2E_PLAYWRIGHT_ONION_NAVIGATION_TIMEOUT if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else TEST_E2E_PLAYWRIGHT_NAVIGATION_TIMEOUT)]
+ ['-v', _playwright_stage_dir ~ ':/e2e']
+ ['-v', _playwright_stage_dir ~ '/volume:/volume']
+ ['-v', _playwright_reports_dir ~ ':/reports']
+10
View File
@@ -43,3 +43,13 @@ TEST_E2E_PLAYWRIGHT_ONION_PROBE_RETRIES: 40
TEST_E2E_PLAYWRIGHT_ONION_PROBE_DELAY: 15
TEST_E2E_PLAYWRIGHT_NAVIGATION_TIMEOUT: 60000
TEST_E2E_PLAYWRIGHT_ONION_NAVIGATION_TIMEOUT: 180000
TEST_E2E_PLAYWRIGHT_EXPECT_TIMEOUT: 10000
TEST_E2E_PLAYWRIGHT_ONION_EXPECT_TIMEOUT: 30000
TEST_E2E_PLAYWRIGHT_ACTION_TIMEOUT: 30000
TEST_E2E_PLAYWRIGHT_ONION_ACTION_TIMEOUT: 90000
TEST_E2E_PLAYWRIGHT_CONFIG_TIMEOUTS:
PLAYWRIGHT_TEST_TIMEOUT: "{{ TEST_E2E_PLAYWRIGHT_ONION_TEST_TIMEOUT if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else test_e2e_playwright_test_timeout }}"
PLAYWRIGHT_EXPECT_TIMEOUT: "{{ TEST_E2E_PLAYWRIGHT_ONION_EXPECT_TIMEOUT if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else TEST_E2E_PLAYWRIGHT_EXPECT_TIMEOUT }}"
PLAYWRIGHT_NAVIGATION_TIMEOUT: "{{ TEST_E2E_PLAYWRIGHT_ONION_NAVIGATION_TIMEOUT if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else TEST_E2E_PLAYWRIGHT_NAVIGATION_TIMEOUT }}"
PLAYWRIGHT_ACTION_TIMEOUT: "{{ TEST_E2E_PLAYWRIGHT_ONION_ACTION_TIMEOUT if (TEST_E2E_PLAYWRIGHT_IS_ONION | bool) else TEST_E2E_PLAYWRIGHT_ACTION_TIMEOUT }}"
+12 -1
View File
@@ -34,6 +34,17 @@ fi
HELPERS_SRC="${REPO_ROOT}/roles/test-e2e-playwright/files"
PERSONAS_SRC="${HELPERS_SRC}/personas"
PLAYWRIGHT_CONFIG_SRC="${HELPERS_SRC}/playwright.config.js"
ROLE_VARS_SRC="${REPO_ROOT}/roles/test-e2e-playwright/vars/main.yml"
mapfile -t CONFIG_TIMEOUT_ENV < <(
sed -n '/^TEST_E2E_PLAYWRIGHT_CONFIG_TIMEOUTS:/,/^[^[:space:]#]/p' "${ROLE_VARS_SRC}" |
grep -oE '^[[:space:]]+[A-Z][A-Z0-9_]+:' | tr -d ' :' | sed 's/$/=1/'
)
if [[ ${#CONFIG_TIMEOUT_ENV[@]} -eq 0 ]]; then
echo "lint-playwright: no timeouts read from ${ROLE_VARS_SRC}; every config load would throw" >&2
exit 2
fi
for asset in "${PERSONAS_SRC}" "${PLAYWRIGHT_CONFIG_SRC}"; do
if [[ ! -e "${asset}" ]]; then
@@ -125,7 +136,7 @@ lint_one_role() {
ln -sfn "${REPO_ROOT}/node_modules" "${stage_dir}/node_modules"
local stub_env=()
local stub_env=("${CONFIG_TIMEOUT_ENV[@]}")
local env_template="roles/${role}/templates/playwright.env.j2"
if [[ -f "${env_template}" ]]; then
local key
@@ -0,0 +1,77 @@
"""One source owns every Playwright timeout, and the config keeps no copy.
The config and the role each held a literal for the same value. They drifted
without a sound: ``actionTimeout`` carried ``|| 30_000`` while nothing ever set
the variable, so every run used the unscaled 30 s, and ``expect`` had no entry
at all, leaving Playwright's built-in 5 s to govern every assertion over Tor
while its neighbours were scaled. The second login attempt in
``web-app-nextcloud`` died on ``expect(usernameField).toBeVisible()`` after 5 s
with 150 s around it.
``vars/main.yml`` now owns the values, the harness renders them into the staged
``.env`` that both the deploy and ``scripts/tests/e2e/rerun-spec.sh`` pass, and
the config demands them rather than inventing one.
"""
from __future__ import annotations
import re
import unittest
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
from . import PROJECT_ROOT
_ROLE = PROJECT_ROOT / "roles/test-e2e-playwright"
_CONFIG = _ROLE / "files/playwright.config.js"
_VARS = _ROLE / ROLE_FILE_VARS_MAIN
_LINT = PROJECT_ROOT / "scripts/lint/playwright.sh"
_SPOT = "TEST_E2E_PLAYWRIGHT_CONFIG_TIMEOUTS"
_REQUIRED = re.compile(r'requiredTimeout\("(PLAYWRIGHT_\w+)"\)')
_ANY_READ = re.compile(r"process\.env\.(PLAYWRIGHT_\w*TIMEOUT\w*)")
class TestConfigTimeoutsHaveOneSource(unittest.TestCase):
def test_the_config_demands_exactly_what_the_role_renders(self) -> None:
demanded = set(_REQUIRED.findall(read_text(str(_CONFIG))))
self.assertTrue(
demanded,
"no requiredTimeout call found in playwright.config.js; the regex "
"no longer matches how the config takes its timeouts, so this test "
"has stopped guarding anything",
)
rendered = set(load_yaml_any(str(_VARS))[_SPOT])
self.assertEqual(
demanded,
rendered,
f"the config and {_SPOT} must name the same timeouts: a demanded "
"one nobody renders aborts every run, and a rendered one nobody "
"demands is dead weight that reads as configuration",
)
def test_the_lint_stager_reads_the_same_map(self) -> None:
self.assertIn(
_SPOT,
read_text(str(_LINT)),
f"{_LINT.name} stages the config and runs 'playwright test --list', "
"which loads it and therefore hits the same demand. It has to take "
f"the names from {_SPOT}, or a rename leaves every role red with a "
"missing-variable abort that has nothing to do with its spec",
)
def test_the_config_keeps_no_timeout_of_its_own(self) -> None:
stray = set(_ANY_READ.findall(read_text(str(_CONFIG))))
self.assertEqual(
set(),
stray,
"these timeouts are read straight from the environment, so a "
"literal beside them becomes a second source the role cannot see: "
+ ", ".join(sorted(stray)),
)
if __name__ == "__main__": # pragma: no cover
unittest.main()