parent
ae3749bab9
commit
9f5aa33e95
|
|
@ -36,9 +36,28 @@ jobs:
|
||||||
name: release-bundle
|
name: release-bundle
|
||||||
path: dist/
|
path: dist/
|
||||||
|
|
||||||
|
browser-journey:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-release
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with: { python-version: "3.11" }
|
||||||
|
- name: Download assembled release bundle
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-bundle
|
||||||
|
path: dist
|
||||||
|
- name: Install browser test dependencies
|
||||||
|
run: |
|
||||||
|
pip install -r requirements-e2e.txt
|
||||||
|
python3 -m playwright install --with-deps chromium
|
||||||
|
- name: Exercise mobile offline filing from the release artifact
|
||||||
|
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
|
||||||
|
|
||||||
release-candidate:
|
release-candidate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, build-release]
|
needs: [lint, build-release, browser-journey]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
|
||||||
|
|
@ -5055,7 +5055,7 @@
|
||||||
const evidence = await createIssueAttachmentController.serialize();
|
const evidence = await createIssueAttachmentController.serialize();
|
||||||
const captureDraft = currentIssueCaptureDraft();
|
const captureDraft = currentIssueCaptureDraft();
|
||||||
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
||||||
if (showDraftCapacityDialog(unfiledCaptures)) return;
|
if (showDraftCapacityDialog(unfiledCaptures, qs)) return;
|
||||||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||||||
if (rUC) {
|
if (rUC) {
|
||||||
await unfiledCaptures.completeResume(rUC);
|
await unfiledCaptures.completeResume(rUC);
|
||||||
|
|
@ -5080,7 +5080,10 @@
|
||||||
qs('#create-issue-title').focus();
|
qs('#create-issue-title').focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
bindDraftCapacityDialog();
|
bindDraftCapacityDialog({
|
||||||
|
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||||||
|
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||||||
|
});
|
||||||
qs('#use-shared-content').addEventListener('click', () => {
|
qs('#use-shared-content').addEventListener('click', () => {
|
||||||
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
|
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
|
||||||
else issueCapture.acceptSharedContent();
|
else issueCapture.acceptSharedContent();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
function showDraftCapacityDialog(captures) {
|
function showDraftCapacityDialog(captures, qs) {
|
||||||
const state = captures.capacity();
|
const state = captures.capacity();
|
||||||
if (!state.full) return false;
|
if (!state.full) return false;
|
||||||
const oldest = state.oldest;
|
const oldest = state.oldest;
|
||||||
|
|
@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindDraftCapacityDialog() {
|
function bindDraftCapacityDialog({
|
||||||
|
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||||||
|
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||||||
|
}) {
|
||||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||||
qs('#draft-capacity-sheet').hidden = true;
|
qs('#draft-capacity-sheet').hidden = true;
|
||||||
qs('#save-unfiled-issue').focus();
|
qs('#save-unfiled-issue').focus();
|
||||||
|
|
|
||||||
2
requirements-e2e.txt
Normal file
2
requirements-e2e.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-r requirements.txt
|
||||||
|
playwright==1.55.0
|
||||||
82
tests/e2e/fake_gitea.py
Normal file
82
tests/e2e/fake_gitea.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Deterministic Gitea double used by the release-artifact browser journey."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
REPOSITORY = {
|
||||||
|
"id": 7,
|
||||||
|
"name": "mobile",
|
||||||
|
"full_name": "acme/mobile",
|
||||||
|
"description": "Mobile filing acceptance repository",
|
||||||
|
"html_url": "http://127.0.0.1/acme/mobile",
|
||||||
|
"owner": {"login": "acme"},
|
||||||
|
}
|
||||||
|
USER = {"id": 1, "login": "timmy", "full_name": "Timmy"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGiteaServer(ThreadingHTTPServer):
|
||||||
|
def __init__(self, address: tuple[str, int]):
|
||||||
|
super().__init__(address, FakeGiteaHandler)
|
||||||
|
self.created_issues: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||||
|
server: FakeGiteaServer
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _json(self, status: int, payload: object, **headers: str) -> None:
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
for name, value in headers.items():
|
||||||
|
self.send_header(name, value)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if path == "/api/v1/user":
|
||||||
|
self._json(200, USER)
|
||||||
|
elif path == "/api/v1/user/repos":
|
||||||
|
self._json(200, [REPOSITORY], **{"X-Total-Count": "1"})
|
||||||
|
elif path in {"/api/v1/repos/acme/mobile", "/api/v1/repos/acme/mobile/"}:
|
||||||
|
self._json(200, REPOSITORY)
|
||||||
|
elif path == "/api/v1/repos/search":
|
||||||
|
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||||
|
elif path.startswith("/api/v1/"):
|
||||||
|
self._json(200, [], **{"X-Total-Count": "0"})
|
||||||
|
else:
|
||||||
|
self._json(404, {"message": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
if path != "/api/v1/repos/acme/mobile/issues":
|
||||||
|
self._json(404, {"message": "not found"})
|
||||||
|
return
|
||||||
|
self.server.created_issues.append(payload)
|
||||||
|
assignee = payload.get("assignee")
|
||||||
|
self._json(
|
||||||
|
201,
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"number": 41,
|
||||||
|
"title": payload.get("title", ""),
|
||||||
|
"body": payload.get("body", ""),
|
||||||
|
"state": "open",
|
||||||
|
"html_url": "http://127.0.0.1/acme/mobile/issues/41",
|
||||||
|
"assignees": [{"login": assignee}] if assignee else [],
|
||||||
|
"labels": [],
|
||||||
|
"milestone": None,
|
||||||
|
"due_date": payload.get("due_date"),
|
||||||
|
"updated_at": "2026-08-15T12:00:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
251
tests/e2e/test_mobile_offline_issue_release.py
Normal file
251
tests/e2e/test_mobile_offline_issue_release.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("playwright.sync_api")
|
||||||
|
from playwright.sync_api import Page, expect, sync_playwright
|
||||||
|
|
||||||
|
from fake_gitea import FakeGiteaServer
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
TITLE = "Offline artifact journey 863"
|
||||||
|
BODY = "Captured on a phone, retained offline, delivered exactly once."
|
||||||
|
ACCESS_TOKEN = "artifact-browser-access-token-863"
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_health(url: str, process: subprocess.Popen[str]) -> None:
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
tls = ssl._create_unverified_context()
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
stdout, stderr = process.communicate()
|
||||||
|
raise AssertionError(f"release server exited early\nstdout:\n{stdout}\nstderr:\n{stderr}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=0.5, context=tls) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise AssertionError("release server did not become healthy")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||||
|
release_root = tmp_path / "release"
|
||||||
|
release_root.mkdir()
|
||||||
|
with tarfile.open(archive, "r:gz") as bundle:
|
||||||
|
bundle.extractall(release_root, filter="data")
|
||||||
|
assert (release_root / "release-manifest.json").is_file()
|
||||||
|
assert not (release_root / "tests").exists(), "journey must run the packaged artifact, not checkout code"
|
||||||
|
|
||||||
|
port = free_port()
|
||||||
|
state_dir = tmp_path / "state"
|
||||||
|
state_dir.mkdir()
|
||||||
|
key_file = tmp_path / "localhost.key"
|
||||||
|
certificate_file = tmp_path / "localhost.crt"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-keyout", str(key_file), "-out", str(certificate_file), "-days", "1",
|
||||||
|
"-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
origin = f"https://127.0.0.1:{port}"
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONPATH": str(release_root),
|
||||||
|
"GITEA_URL": gitea_url,
|
||||||
|
"GITEA_TOKEN": "deterministic-e2e-token",
|
||||||
|
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
||||||
|
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
||||||
|
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
||||||
|
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
||||||
|
}
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable, "-m", "uvicorn", "src.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(port),
|
||||||
|
"--ssl-keyfile", str(key_file), "--ssl-certfile", str(certificate_file),
|
||||||
|
],
|
||||||
|
cwd=release_root,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
wait_for_health(origin + "/healthz", process)
|
||||||
|
yield origin
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.communicate(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.communicate()
|
||||||
|
|
||||||
|
|
||||||
|
def indexed_issue_records(page: Page) -> list[dict]:
|
||||||
|
return page.evaluate(
|
||||||
|
"""async () => {
|
||||||
|
const db = await new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('stackchain-background-outbox-v1', 1);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const request = db.transaction('issues', 'readonly').objectStore('issues').getAll();
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_reload(tmp_path: Path):
|
||||||
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
|
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||||
|
|
||||||
|
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||||
|
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||||
|
fake_thread.start()
|
||||||
|
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||||
|
browser_errors: list[str] = []
|
||||||
|
failed_responses: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={"width": 390, "height": 844},
|
||||||
|
service_workers="allow",
|
||||||
|
ignore_https_errors=True,
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||||
|
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||||
|
page.on("response", lambda response: failed_responses.append(
|
||||||
|
f"{response.status} {response.url}"
|
||||||
|
) if response.status >= 400 else None)
|
||||||
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
expect(page.locator("#sign-in")).to_be_visible()
|
||||||
|
page.locator('input[name="device_label"]').fill("Release journey phone")
|
||||||
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
|
page.locator("#submit-sign-in").click()
|
||||||
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
new_action = page.locator('[data-mobile-task="new"]')
|
||||||
|
expect(new_action).to_be_visible()
|
||||||
|
service_worker_ready = page.evaluate(
|
||||||
|
"""() => Promise.race([
|
||||||
|
navigator.serviceWorker.ready.then(() => true),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve(false), 10000)),
|
||||||
|
])"""
|
||||||
|
)
|
||||||
|
assert service_worker_ready, (
|
||||||
|
"release artifact did not register its service worker; browser errors=" + repr(browser_errors)
|
||||||
|
+ "; failed responses=" + repr(failed_responses)
|
||||||
|
)
|
||||||
|
if not page.evaluate("() => navigator.serviceWorker.controller !== null"):
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
new_action = page.locator('[data-mobile-task="new"]')
|
||||||
|
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||||
|
|
||||||
|
new_action.click()
|
||||||
|
page.locator("#create-issue-title").fill(TITLE)
|
||||||
|
page.locator("#create-issue-body").fill(BODY)
|
||||||
|
page.locator("#file-new-issue").click()
|
||||||
|
expect(page.locator("#create-issue-filing")).to_be_visible()
|
||||||
|
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||||
|
expect(page.locator("#submit-new-issue")).to_be_enabled()
|
||||||
|
|
||||||
|
context.set_offline(True)
|
||||||
|
page.locator("#submit-new-issue").click()
|
||||||
|
expect(page.locator("#issue-filing-review")).to_be_visible()
|
||||||
|
page.locator("#confirm-issue-filing").click()
|
||||||
|
page.wait_for_timeout(1_000)
|
||||||
|
filing_statuses = page.locator(
|
||||||
|
"#my-work-action-status, #create-issue-status, #issue-filing-review-status"
|
||||||
|
).all_inner_texts()
|
||||||
|
assert any(
|
||||||
|
marker in " ".join(filing_statuses).lower()
|
||||||
|
for marker in ("queued", "saved for", "background delivery")
|
||||||
|
), (filing_statuses, browser_errors, failed_responses)
|
||||||
|
|
||||||
|
local_record = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||||
|
assert [(item["title"], item["body"], item["repository"]) for item in local_record["items"]] == [
|
||||||
|
(TITLE, BODY, "acme/mobile")
|
||||||
|
]
|
||||||
|
durable = indexed_issue_records(page)
|
||||||
|
assert [(item["title"], item["body"], item["repository"]) for item in durable] == [
|
||||||
|
(TITLE, BODY, "acme/mobile")
|
||||||
|
]
|
||||||
|
operation_id = durable[0]["operationId"]
|
||||||
|
assert operation_id and operation_id == local_record["items"][0]["operationId"]
|
||||||
|
assert fake.created_issues == []
|
||||||
|
|
||||||
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||||
|
durable_after_reload = indexed_issue_records(page)
|
||||||
|
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
||||||
|
assert durable_after_reload[0]["operationId"] == operation_id
|
||||||
|
assert fake.created_issues == []
|
||||||
|
|
||||||
|
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||||
|
failed_responses.clear()
|
||||||
|
context.set_offline(False)
|
||||||
|
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||||
|
for _ in range(80):
|
||||||
|
if fake.created_issues:
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
||||||
|
|
||||||
|
for _ in range(40):
|
||||||
|
durable_completion = indexed_issue_records(page)
|
||||||
|
if durable_completion and all(item.get("status") == "sent" for item in durable_completion):
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||||
|
for _ in range(40):
|
||||||
|
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||||
|
if completed_local["items"] == []:
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
assert completed_local["items"] == []
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
assert len(fake.created_issues) == 1
|
||||||
|
assert browser_errors == []
|
||||||
|
assert failed_responses == []
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
fake.shutdown()
|
||||||
|
fake.server_close()
|
||||||
|
fake_thread.join(timeout=5)
|
||||||
|
|
@ -21,8 +21,21 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||||
text = WORKFLOW.read_text()
|
text = WORKFLOW.read_text()
|
||||||
release = text[text.index(" release-candidate:") :]
|
release = text[text.index(" release-candidate:") :]
|
||||||
|
|
||||||
assert "needs: [lint, build-release]" in release
|
assert "needs: [lint, build-release, browser-journey]" in release
|
||||||
assert "github.event_name == 'push'" in release
|
assert "github.event_name == 'push'" in release
|
||||||
assert "actions/download-artifact@v3" in release
|
assert "actions/download-artifact@v3" in release
|
||||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||||
|
text = WORKFLOW.read_text()
|
||||||
|
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||||
|
release = text[text.index(" release-candidate:") :]
|
||||||
|
|
||||||
|
assert "needs: build-release" in browser
|
||||||
|
assert "actions/download-artifact@v3" in browser
|
||||||
|
assert "pip install -r requirements-e2e.txt" in browser
|
||||||
|
assert "python3 -m playwright install --with-deps chromium" in browser
|
||||||
|
assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser
|
||||||
|
assert "needs: [lint, build-release, browser-journey]" in release
|
||||||
|
|
|
||||||
|
|
@ -739,7 +739,8 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision(
|
||||||
assert 'id="review-full-drafts"' in html
|
assert 'id="review-full-drafts"' in html
|
||||||
assert 'id="replace-oldest-draft"' in html
|
assert 'id="replace-oldest-draft"' in html
|
||||||
assert 'id="keep-editing-draft"' in html
|
assert 'id="keep-editing-draft"' in html
|
||||||
assert "showDraftCapacityDialog(unfiledCaptures)" in html
|
assert "showDraftCapacityDialog(unfiledCaptures, qs)" in html
|
||||||
|
assert "bindDraftCapacityDialog({" in html
|
||||||
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
||||||
assert "const draft = currentIssueCaptureDraft();" in feature
|
assert "const draft = currentIssueCaptureDraft();" in feature
|
||||||
assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
|
assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user