Skip to content

Commit 29ebfb0

Browse files
Keep deployed Python promotion qualification out of customer metrics
1 parent 06ca0e7 commit 29ebfb0

6 files changed

Lines changed: 284 additions & 24 deletions

File tree

‎.github/workflows/docs.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,9 @@ jobs:
282282
python -m playwright install --with-deps chromium
283283
284284
- name: Qualify deployed promotion transport
285-
run: python scripts/qualify-docs-promotion.py
285+
run: >-
286+
python scripts/qualify-docs-promotion.py
287+
--source-revision "${{ needs.build.outputs.source_revision }}"
286288
287289
audit-release:
288290
needs: [build, deploy]

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dev = [
3939
"pytest>=8.0",
4040
"pytest-asyncio>=0.23",
4141
"mypy>=1.10",
42+
"playwright>=1.48,<2",
4243
"PyYAML>=6.0",
4344
"ruff>=0.4",
4445
"tomli>=2; python_version < '3.11'",

‎scripts/check-docs-layout.py‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,19 +1274,19 @@ def exercise_promotion_contract(browser: Browser, loopback_url: str) -> None:
12741274
failures: list[str] = []
12751275
context.route(PROMOTION_EVENT_URL, lambda route: route.continue_(url=receiver_url))
12761276
page = context.new_page()
1277-
page.on(
1278-
"console",
1279-
lambda message: failures.append(f"console {message.type}: {message.text}")
1280-
if message.type == "error"
1281-
else None,
1282-
)
1277+
1278+
def record_console_error(message: Any) -> None:
1279+
if message.type == "error":
1280+
failures.append(f"console {message.type}: {message.text}")
1281+
1282+
page.on("console", record_console_error)
12831283
page.on("pageerror", lambda error: failures.append(f"page: {error}"))
1284-
page.on(
1285-
"response",
1286-
lambda response: failures.append(f"http {response.status}: {response.url}")
1287-
if response.status >= 400
1288-
else None,
1289-
)
1284+
1285+
def record_http_failure(response: Response) -> None:
1286+
if response.status >= 400:
1287+
failures.append(f"http {response.status}: {response.url}")
1288+
1289+
page.on("response", record_http_failure)
12901290

12911291
try:
12921292
response = page.goto(f"{docs_origin}/", wait_until="networkidle")
@@ -1320,6 +1320,35 @@ def exercise_promotion_contract(browser: Browser, loopback_url: str) -> None:
13201320
)
13211321
assert failures == [], f"deployed-host promotion emitted browser or receiver errors: {failures}"
13221322
assert context.cookies() == [], "promotion browser contract created or received cookies"
1323+
1324+
page.remove_listener("response", record_http_failure)
1325+
page.remove_listener("console", record_console_error)
1326+
rejected_statuses = page.evaluate(
1327+
"""async ({url, source}) => {
1328+
const invalidPayloads = [
1329+
{source: `${source}-unbounded`, event: 'impression'},
1330+
{source, event: 'unbounded'},
1331+
{source, event: 'click', visitor: 'stable-id'},
1332+
]
1333+
return Promise.all(invalidPayloads.map(async body => {
1334+
const response = await fetch(url, {
1335+
method: 'POST',
1336+
mode: 'cors',
1337+
credentials: 'omit',
1338+
referrerPolicy: 'origin',
1339+
headers: {'Content-Type': 'text/plain'},
1340+
body: JSON.stringify(body),
1341+
})
1342+
return response.status
1343+
}))
1344+
}""",
1345+
{"url": PROMOTION_EVENT_URL, "source": PROMOTION_SOURCE},
1346+
)
1347+
assert rejected_statuses == [422, 422, 422], (
1348+
f"strict promotion receiver accepted an unbounded source, event, or payload: {rejected_statuses}"
1349+
)
1350+
assert [request["status"] for request in requests[-3:]] == [422, 422, 422]
1351+
assert failures == [], f"promotion validation emitted console or page errors: {failures}"
13231352
finally:
13241353
context.close()
13251354

‎scripts/qualify-docs-promotion.py‎

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,64 @@
66
import argparse
77
import json
88
import os
9+
import sys
910
from collections.abc import Sequence
11+
from pathlib import Path
1012
from typing import Any
1113
from urllib.parse import urlparse
1214

13-
from playwright.sync_api import Browser, Request, Response, sync_playwright
15+
# Direct file execution adds ``scripts/`` rather than the repository root to
16+
# sys.path. The deployment workflow uses this entrypoint, so make its local
17+
# package imports resolvable before importing the qualifier dependencies.
18+
REPO_ROOT = Path(__file__).resolve().parent.parent
19+
if str(REPO_ROOT) not in sys.path:
20+
sys.path.insert(0, str(REPO_ROOT))
21+
22+
from playwright.sync_api import Browser, Request, Response, sync_playwright # noqa: E402
23+
from scripts.api_reference_release import load_release_identity # noqa: E402
24+
from scripts.check_api_reference_install import verify_public_deployment # noqa: E402
1425

1526
DOCS_URL = "https://python.durable-workflow.com/"
27+
RELEASE_AUDIT_URL = f"{DOCS_URL}release-audit.json"
1628
DESTINATION_URL = "https://cloud.durable-workflow.com/early-access#source=sdk-python-reference"
1729
PROMOTION_EVENT_URL = "https://cloud.durable-workflow.com/early-access/promotion-events"
1830
PROMOTION_SOURCE = "sdk-python-reference"
31+
QUALIFICATION_EVENT = "qualification"
1932
VIEWPORTS = (
2033
("desktop", 1440, 900),
2134
("intermediate", 768, 1024),
2235
("mobile", 390, 844),
2336
("short-height", 640, 360),
2437
)
2538

39+
QUALIFICATION_REWRITE_SCRIPT = f"""
40+
(() => {{
41+
const eventUrl = {json.dumps(PROMOTION_EVENT_URL)};
42+
const source = {json.dumps(PROMOTION_SOURCE)};
43+
const qualificationEvent = {json.dumps(QUALIFICATION_EVENT)};
44+
const nativeFetch = window.fetch.bind(window);
45+
46+
window.fetch = function (input, init) {{
47+
const requestUrl = typeof input === 'string' ? input : input.url;
48+
if (requestUrl !== eventUrl) return nativeFetch(input, init);
49+
50+
const options = init || {{}};
51+
let initiatedPayload = null;
52+
try {{
53+
initiatedPayload = JSON.parse(options.body);
54+
}} catch (_error) {{
55+
// The qualification fails on the recorded initiation shape below.
56+
}}
57+
window.recordPromotionQualificationInitiation(initiatedPayload);
58+
59+
return nativeFetch(input, {{
60+
...options,
61+
body: JSON.stringify({{source, event: qualificationEvent}}),
62+
}});
63+
}};
64+
}})();
65+
"""
66+
2667

2768
def event_payload(request: Request) -> dict[str, Any] | None:
2869
try:
@@ -68,6 +109,13 @@ def qualify_viewport(browser: Browser, name: str, width: int, height: int) -> No
68109
page = context.new_page()
69110
errors: list[str] = []
70111
promotion_requests: list[Request] = []
112+
initiated_events: list[object] = []
113+
114+
def record_initiated_event(payload: object) -> None:
115+
initiated_events.append(payload)
116+
117+
page.expose_function("recordPromotionQualificationInitiation", record_initiated_event)
118+
page.add_init_script(QUALIFICATION_REWRITE_SCRIPT)
71119

72120
page.on(
73121
"console",
@@ -81,20 +129,22 @@ def qualify_viewport(browser: Browser, name: str, width: int, height: int) -> No
81129

82130
try:
83131
action = page.locator('[data-promotion-action="early-access"]')
84-
with page.expect_response(lambda response: is_event(response, "impression"), timeout=30_000) as pending:
132+
with page.expect_response(lambda response: is_event(response, QUALIFICATION_EVENT), timeout=30_000) as pending:
85133
document = page.goto(DOCS_URL, wait_until="domcontentloaded", timeout=30_000)
86134
action.scroll_into_view_if_needed()
87135
assert document is not None and document.ok, f"deployed docs returned HTTP {document.status} at {name}"
88-
assert_event_response(pending.value, "impression")
136+
assert_event_response(pending.value, QUALIFICATION_EVENT)
89137

90138
action.wait_for(state="visible")
91139
assert action.get_attribute("href") == DESTINATION_URL, "promotion destination changed"
92140
with (
93-
page.expect_response(lambda response: is_event(response, "click"), timeout=30_000) as click_pending,
141+
page.expect_response(
142+
lambda response: is_event(response, QUALIFICATION_EVENT), timeout=30_000
143+
) as click_pending,
94144
page.expect_navigation(wait_until="domcontentloaded", timeout=30_000) as navigation,
95145
):
96146
action.click()
97-
assert_event_response(click_pending.value, "click")
147+
assert_event_response(click_pending.value, QUALIFICATION_EVENT)
98148

99149
destination = navigation.value
100150
assert destination is not None and destination.status == 200, (
@@ -115,11 +165,15 @@ def qualify_viewport(browser: Browser, name: str, width: int, height: int) -> No
115165
)
116166

117167
page.wait_for_timeout(250)
118-
observed = [event_payload(request) for request in promotion_requests]
119-
assert observed == [
168+
assert initiated_events == [
120169
{"source": PROMOTION_SOURCE, "event": "impression"},
121170
{"source": PROMOTION_SOURCE, "event": "click"},
122-
], f"promotion emitted duplicate or unbounded events at {name}: {observed}"
171+
], f"deployed promotion initiated unexpected events at {name}: {initiated_events}"
172+
observed = [event_payload(request) for request in promotion_requests]
173+
assert observed == [
174+
{"source": PROMOTION_SOURCE, "event": QUALIFICATION_EVENT},
175+
{"source": PROMOTION_SOURCE, "event": QUALIFICATION_EVENT},
176+
], f"promotion qualification emitted duplicate or unbounded events at {name}: {observed}"
123177
assert errors == [], f"promotion emitted browser errors at {name}: {errors}"
124178
finally:
125179
context.close()
@@ -132,11 +186,36 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
132186
default=os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE"),
133187
help="Use a system Chromium instead of Playwright's managed browser.",
134188
)
189+
parser.add_argument(
190+
"--source-revision",
191+
required=True,
192+
help="Exact deployed source revision required before live qualification begins.",
193+
)
194+
parser.add_argument(
195+
"--release-audit-attempts",
196+
type=int,
197+
default=12,
198+
help="Number of attempts allowed while the live release record converges.",
199+
)
200+
parser.add_argument(
201+
"--release-audit-retry-sleep",
202+
type=float,
203+
default=10,
204+
help="Seconds between live release-record attempts.",
205+
)
135206
return parser.parse_args(argv)
136207

137208

138209
def main(argv: Sequence[str] | None = None) -> int:
139210
args = parse_args(argv)
211+
verify_public_deployment(
212+
RELEASE_AUDIT_URL,
213+
load_release_identity(REPO_ROOT),
214+
args.source_revision,
215+
attempts=args.release_audit_attempts,
216+
retry_sleep=args.release_audit_retry_sleep,
217+
)
218+
140219
with sync_playwright() as playwright:
141220
launch_options = {"headless": True}
142221
if args.chromium_executable:
@@ -149,8 +228,9 @@ def main(argv: Sequence[str] | None = None) -> int:
149228
browser.close()
150229

151230
print(
152-
"Confirmed one successful source-attributed impression and click, a healthy early-access destination, "
153-
"and no browser errors at desktop, intermediate, mobile, and short-height viewports."
231+
f"Confirmed deployed revision {args.source_revision}, two non-aggregating qualification requests, "
232+
"the source-attributed impression/click initiation and destination behavior, and no browser errors at "
233+
"desktop, intermediate, mobile, and short-height viewports."
154234
)
155235
return 0
156236

0 commit comments

Comments
 (0)