Bound Playwright dependency setup before E2E execution

The retained PR2697 shard waited on apt indexes for the whole one-hour job without starting a test. Bound HTTP/HTTPS acquisition and retries, reject incomplete indexes, and cap only the browser setup phase at ten minutes in all three hosted journeys. Preserve the existing repositories, authentication, browser selections and test/verdict gates.

Contract-Neutral: Hosted CI dependency acquisition only; no product runtime, installer, release contract or browser-source change.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-08 09:47:17 +01:00
parent fe246963e0
commit 2d3890d6be
3 changed files with 192 additions and 2 deletions
+29 -2
View File
@@ -12,6 +12,8 @@ on:
- 'tests/integration/**'
- 'Dockerfile'
- '.github/workflows/test-e2e.yml'
- 'scripts/playwright-apt.conf'
- 'scripts/tests/test_playwright_acquisition.py'
push:
branches:
- main
@@ -24,6 +26,8 @@ on:
- 'tests/integration/**'
- 'Dockerfile'
- '.github/workflows/test-e2e.yml'
- 'scripts/playwright-apt.conf'
- 'scripts/tests/test_playwright_acquisition.py'
workflow_dispatch:
# Let branch runs finish so busy main and release branches retain verdicts.
@@ -51,6 +55,9 @@ jobs:
- name: Check shipped docs before dependency work
run: python3 scripts/check_docs_mirror.py
- name: Validate browser dependency acquisition policy
run: python3 scripts/tests/test_playwright_acquisition.py
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -103,13 +110,21 @@ jobs:
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# Apply only to this disposable hosted runner, never the maintainer host.
# Keep the provider's repositories and apt authentication unchanged.
- name: Bound browser OS dependency acquisition
run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire
- name: Install locked dependencies
run: |
npm ci --prefix tests/integration
npm ci --prefix frontend-modern
npm ci --prefix internal/cloudcp/portal/frontend
cd tests/integration
npx playwright install --with-deps chromium
- name: Install Playwright browser and OS dependencies
timeout-minutes: 10
working-directory: tests/integration
run: npx playwright install --with-deps chromium
- name: Validate offline issuer boundaries
working-directory: tests/integration
run: >-
@@ -156,7 +171,13 @@ jobs:
cache: 'npm'
cache-dependency-path: tests/integration/package-lock.json
# Apply only to this disposable hosted runner, never the maintainer host.
# Keep the provider's repositories and apt authentication unchanged.
- name: Bound browser OS dependency acquisition
run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire
- name: Install Playwright dependencies
timeout-minutes: 10
working-directory: tests/integration
run: |
npm ci
@@ -315,7 +336,13 @@ jobs:
node-version: '24'
package-manager-cache: false
# Apply only to this disposable hosted runner, never the maintainer host.
# Keep the provider's repositories and apt authentication unchanged.
- name: Bound browser OS dependency acquisition
run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire
- name: Install Playwright dependencies
timeout-minutes: 10
working-directory: tests/integration
run: |
npm ci
+6
View File
@@ -0,0 +1,6 @@
// Bound apt transport inside disposable GitHub E2E runners. Do not replace
// repositories, disable TLS/signatures or accept an incomplete index as success.
Acquire::http::Timeout "30";
Acquire::https::Timeout "30";
Acquire::Retries "2";
APT::Update::Error-Mode "any";
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Keep failed browser setup out of the E2E test budget, without stale apt success."""
import os
from pathlib import Path
import re
import socket
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = ROOT / ".github/workflows/test-e2e.yml"
POLICY = ROOT / "scripts/playwright-apt.conf"
INSTALL_POLICY = (
"sudo install -m 0644 scripts/playwright-apt.conf "
"/etc/apt/apt.conf.d/99pulse-playwright-acquire"
)
def job_block(workflow, job):
return re.split(r"(?m)^ \S", workflow.split(f"\n {job}:\n", 1)[1], maxsplit=1)[0]
def step_blocks(job):
return re.split(r"(?m)^ - name: ", job)[1:]
class PlaywrightAcquisitionTest(unittest.TestCase):
def assert_workflow_policy(self, workflow):
for job in ("offline-org-provisioning", "e2e", "agent-registration"):
with self.subTest(job=job):
block = job_block(workflow, job)
steps = step_blocks(block)
policies = [i for i, step in enumerate(steps) if INSTALL_POLICY in step]
installers = [
(i, step) for i, step in enumerate(steps)
if "npx playwright install --with-deps " in step
]
self.assertEqual(len(policies), 1)
self.assertEqual(len(installers), 1)
i, install = installers[0]
self.assertLess(policies[0], i)
for step in (steps[policies[0]], install):
self.assertNotIn("continue-on-error", step)
self.assertNotIn("if:", step)
self.assertNotIn("||", step)
self.assertIn("timeout-minutes: 10", install)
self.assertIn("working-directory: tests/integration", install)
browsers = "chromium webkit" if job == "e2e" else "chromium"
self.assertIn(f"npx playwright install --with-deps {browsers}\n", install)
# Browser setup must finish successfully before real journeys.
self.assertLess(i, next(
n for n, step in enumerate(steps)
if step.startswith("Run stable-tier E2E suite")
or step.startswith("Prove authenticated default")
or step.startswith("Run agent registration lifecycle")
))
def test_all_browser_jobs_bound_setup_without_weakening_gates(self):
self.assert_workflow_policy(WORKFLOW.read_text())
def test_policy_and_tests_trigger_both_pr_and_main_validation(self):
workflow = WORKFLOW.read_text()
for event in ("pull_request", "push"):
block = re.split(r"(?m)^ \S", workflow.split(f" {event}:\n", 1)[1], maxsplit=1)[0]
self.assertIn("'scripts/playwright-apt.conf'", block)
self.assertIn("'scripts/tests/test_playwright_acquisition.py'", block)
selection = job_block(workflow, "tier-selection")
self.assertLess(
selection.index("python3 scripts/tests/test_playwright_acquisition.py"),
selection.index("npm ci"),
)
def test_unbounded_and_ignored_setup_mutants_are_rejected(self):
workflow = WORKFLOW.read_text()
# Each mutation targets the fault, not an unrelated YAML assertion.
for original, replacement in (
(" timeout-minutes: 10\n", ""),
(INSTALL_POLICY, "echo no acquisition policy"),
("run: npx playwright install --with-deps chromium\n",
"run: npx playwright install --with-deps chromium || true\n"),
):
with self.subTest(mutation=replacement):
# subTest failures cannot be caught as an ordinary assertion,
# so collect the mutant's real unittest result independently.
class Mutant(PlaywrightAcquisitionTest):
def runTest(inner):
inner.assert_workflow_policy(workflow.replace(original, replacement))
result = unittest.TestResult()
Mutant().run(result)
self.assertFalse(result.wasSuccessful())
self.assertTrue(result.failures)
self.assertFalse(result.errors)
def test_real_apt_parser_loads_only_the_four_expected_controls(self):
with tempfile.TemporaryDirectory() as temp:
config = Path(temp) / "apt.conf"
config.write_text(
'Dir::Etc::parts "-";\nDir::Etc::main "-";\n' + POLICY.read_text()
)
parsed = subprocess.run(
["apt-config", "dump"], env={**os.environ, "APT_CONFIG": str(config)},
capture_output=True, text=True, timeout=10, check=True,
).stdout
expected = {
"Acquire::http::Timeout": "30",
"Acquire::https::Timeout": "30",
"Acquire::Retries": "2",
"APT::Update::Error-Mode": "any",
}
entries = dict(re.findall(r'^(\S+) "([^"\n]*)";', POLICY.read_text(), re.M))
self.assertEqual(entries, expected)
for key, value in expected.items():
self.assertIn(f'{key} "{value}";', parsed)
self.assertNotIn("AllowUnauthenticated", parsed)
self.assertNotIn("Verify-Peer", parsed)
def test_incomplete_index_is_a_real_apt_failure_not_a_warning_pass(self):
# An unlistening reserved loopback port gives a definite transport
# failure. No external request, privileged apt operation, host list,
# post-update hook or authentication bypass is involved.
with socket.socket() as sock, tempfile.TemporaryDirectory() as temp:
sock.bind(("127.0.0.1", 0))
root = Path(temp)
(root / "lists/partial").mkdir(parents=True)
(root / "cache/archives/partial").mkdir(parents=True)
sources = root / "sources.list"
sources.write_text(f"deb http://127.0.0.1:{sock.getsockname()[1]} stable main\n")
config = root / "apt.conf"
prefix = (
'Dir::Etc::parts "-";\nDir::Etc::main "-";\n'
f'Dir::Etc::sourcelist "{sources}";\nDir::Etc::sourceparts "-";\n'
f'Dir::State::lists "{root / "lists"}";\n'
f'Dir::Cache "{root / "cache"}";\n'
'Dir::State::status "/dev/null";\nDebug::NoLocking "true";\n'
)
for strict, expected_exit in ((True, 100), (False, 0)):
policy = POLICY.read_text()
if not strict:
policy = policy.replace('APT::Update::Error-Mode "any";', "")
config.write_text(prefix + policy)
result = subprocess.run(
["apt-get", "update"],
env={**os.environ, "APT_CONFIG": str(config), "LC_ALL": "C"},
capture_output=True, text=True, timeout=20,
)
with self.subTest(strict=strict):
self.assertEqual(result.returncode, expected_exit, result.stderr)
self.assertIn("Failed to fetch http://127.0.0.1:", result.stderr)
self.assertIn("Connection refused", result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()