mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user