Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_REPO = "warpdotdev/warp"
|
||||
DEFAULT_HOSTNAME = "github.com"
|
||||
FEEDBACK_LABEL = "in-app-feedback"
|
||||
|
||||
# GitHub's new-issue page accepts a prefilled title and body via query
|
||||
# parameters, but browsers and intermediate servers commonly cap URLs around
|
||||
# 8 KB. Keep a conservative threshold that leaves headroom for the base URL
|
||||
# and percent-encoding overhead.
|
||||
MAX_PREFILL_URL_LENGTH = 8000
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"File a GitHub issue in warpdotdev/warp. The caller must choose "
|
||||
"the filing method via --use: `gh` to create the issue directly with the "
|
||||
"gh CLI, or `browser` to open the prefilled new-issue page in the browser "
|
||||
"(used when attachments require manual upload via GitHub's web UI)."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use",
|
||||
dest="use_method",
|
||||
required=True,
|
||||
choices=["gh", "browser"],
|
||||
help=(
|
||||
"Filing method. `gh` creates the issue via the gh CLI (requires gh to be "
|
||||
"installed and authenticated). `browser` opens the prefilled new-issue "
|
||||
"page in the user's browser so attachments can be uploaded via GitHub's "
|
||||
"web UI."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument("--title", required=True, help="Issue title.")
|
||||
parser.add_argument(
|
||||
"--body-file",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Path to a UTF-8 file containing the issue body.",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_text(path: Path, field_name: str) -> str:
|
||||
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise SystemExit(f"Failed to read {field_name} from {path}: {exc}") from exc
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
normalized_title = " ".join(title.splitlines()).strip()
|
||||
if not normalized_title:
|
||||
raise SystemExit("Issue title must not be empty.")
|
||||
return normalized_title
|
||||
|
||||
|
||||
def normalize_body(body: str) -> str:
|
||||
normalized_body = body.rstrip("\n")
|
||||
if not normalized_body.strip():
|
||||
raise SystemExit("Issue body must not be empty.")
|
||||
return normalized_body
|
||||
|
||||
|
||||
def gh_path_if_authenticated() -> str | None:
|
||||
gh_path = shutil.which("gh")
|
||||
if gh_path is None:
|
||||
return None
|
||||
|
||||
auth_result = subprocess.run(
|
||||
[gh_path, "auth", "status", "--hostname", DEFAULT_HOSTNAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if auth_result.returncode != 0:
|
||||
return None
|
||||
|
||||
return gh_path
|
||||
|
||||
|
||||
def create_issue_with_gh(
|
||||
gh_path: str,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
command = [
|
||||
gh_path, "issue", "create",
|
||||
"--repo", DEFAULT_REPO,
|
||||
"--title", title,
|
||||
"--body", body,
|
||||
"--label", FEEDBACK_LABEL,
|
||||
]
|
||||
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
error_output = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part).strip()
|
||||
return None, error_output or "gh issue create failed"
|
||||
|
||||
issue_url = result.stdout.strip().splitlines()[-1].strip()
|
||||
if not issue_url:
|
||||
return None, "gh issue create succeeded but did not return an issue URL"
|
||||
|
||||
return issue_url, None
|
||||
|
||||
|
||||
def build_new_issue_url(title: str, body: str | None) -> str:
|
||||
"""Build a GitHub new-issue URL with the provided title and optional body prefilled."""
|
||||
base = f"https://{DEFAULT_HOSTNAME}/{DEFAULT_REPO}/issues/new"
|
||||
params: list[tuple[str, str]] = [("title", title)]
|
||||
if body is not None:
|
||||
params.append(("body", body))
|
||||
return f"{base}?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}"
|
||||
|
||||
|
||||
def browser_is_available() -> tuple[bool, str | None]:
|
||||
"""Return whether opening a browser is likely to succeed on this system.
|
||||
|
||||
On macOS and Windows a GUI session is effectively always present for the
|
||||
user running this script. On Linux and other unix-likes ``webbrowser.open``
|
||||
can return True without actually opening a browser when no display server is
|
||||
running (for example, in a headless SSH session), so we require ``DISPLAY``
|
||||
or ``WAYLAND_DISPLAY`` to be set.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system in ("Darwin", "Windows"):
|
||||
return True, None
|
||||
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
|
||||
return True, None
|
||||
return (
|
||||
False,
|
||||
(
|
||||
"No graphical display is available (neither DISPLAY nor WAYLAND_DISPLAY "
|
||||
"is set), so the GitHub new-issue page cannot be opened in a browser."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def open_in_browser(url: str) -> bool:
|
||||
try:
|
||||
return webbrowser.open(url, new=2)
|
||||
except webbrowser.Error:
|
||||
return False
|
||||
|
||||
|
||||
def fallback_to_browser(title: str, body: str) -> int:
|
||||
"""Open the GitHub new-issue page with a prefilled title (and body when it fits).
|
||||
|
||||
Intended for the caller-selected ``--use browser`` path, which the feedback skill
|
||||
uses when image attachments need to be uploaded via GitHub's web UI. When the full
|
||||
prefill URL would exceed ``MAX_PREFILL_URL_LENGTH``, the body is omitted from the
|
||||
URL and surfaced in the JSON result under a ``body`` field so the caller can tell
|
||||
the user to paste it into the issue form manually.
|
||||
|
||||
When the browser cannot be opened, the function attempts a ``gh issue create``
|
||||
fallback so the issue is still filed with the available text contents. The returned
|
||||
payload includes ``browser_unavailable: true`` when that fallback is used, so the
|
||||
caller can inform the user that the browser could not be opened and images were not
|
||||
uploaded. If both the browser and ``gh`` are unavailable, filing fails.
|
||||
"""
|
||||
full_url = build_new_issue_url(title, body)
|
||||
body_fits_in_url = len(full_url) <= MAX_PREFILL_URL_LENGTH
|
||||
url_to_open = full_url if body_fits_in_url else build_new_issue_url(title, None)
|
||||
|
||||
browser_available, browser_unavailable_reason = browser_is_available()
|
||||
|
||||
base_payload: dict[str, object] = {
|
||||
"method": "browser",
|
||||
"repo": DEFAULT_REPO,
|
||||
"url": url_to_open,
|
||||
}
|
||||
if not body_fits_in_url:
|
||||
# Surface the body so the caller can instruct the user to paste it in.
|
||||
base_payload["body"] = body
|
||||
|
||||
browser_failure_reason: str | None = None
|
||||
if not browser_available:
|
||||
browser_failure_reason = browser_unavailable_reason or "No display available."
|
||||
elif not open_in_browser(url_to_open):
|
||||
browser_failure_reason = "Unable to open a web browser for the prefilled new-issue page."
|
||||
|
||||
if browser_failure_reason is not None:
|
||||
# Browser unavailable — try gh CLI as a fallback so the issue is still filed.
|
||||
gh_path = gh_path_if_authenticated()
|
||||
if gh_path is not None:
|
||||
issue_url, _gh_error = create_issue_with_gh(gh_path, title, body)
|
||||
if issue_url is not None:
|
||||
print_result({
|
||||
"status": "created",
|
||||
"method": "gh",
|
||||
"repo": DEFAULT_REPO,
|
||||
"issue_url": issue_url,
|
||||
"browser_unavailable": True,
|
||||
"message": (
|
||||
f"{browser_failure_reason} "
|
||||
"The issue was filed programmatically with the available text contents. "
|
||||
"Image attachments were not uploaded."
|
||||
),
|
||||
})
|
||||
return 0
|
||||
|
||||
base_payload["status"] = "failed"
|
||||
base_payload["error"] = (
|
||||
browser_failure_reason
|
||||
+ " Image attachments could not be handed off through the browser flow; "
|
||||
"no issue has been filed."
|
||||
)
|
||||
print_result(base_payload)
|
||||
return 1
|
||||
|
||||
# The browser path is only used by the skill when image attachments are
|
||||
# present, so the user-facing message always references pasting/dropping them.
|
||||
if body_fits_in_url:
|
||||
message = (
|
||||
"Opened the GitHub new-issue page in your browser with the title and body prefilled. "
|
||||
"Paste or drag your attached screenshot(s) into the body at the placeholder line(s), "
|
||||
"then submit the issue."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"Opened the GitHub new-issue page in your browser with the title prefilled. "
|
||||
"The drafted body was too long to include in the URL; paste the body (returned in "
|
||||
"this result's `body` field) into the issue form first, then paste or drag your "
|
||||
"attached screenshot(s) into the placeholder line(s), then submit."
|
||||
)
|
||||
|
||||
base_payload["status"] = "browser_opened"
|
||||
base_payload["message"] = message
|
||||
print_result(base_payload)
|
||||
return 0
|
||||
|
||||
|
||||
def print_result(payload: dict[str, object]) -> None:
|
||||
json.dump(payload, sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def file_with_gh(title: str, body: str) -> int:
|
||||
"""File the issue via the gh CLI. Returns status `unavailable` when gh isn't
|
||||
installed or not authenticated; the caller is responsible for choosing a
|
||||
different --use method in that case (no automatic fallback happens here).
|
||||
"""
|
||||
gh_path = gh_path_if_authenticated()
|
||||
if gh_path is None:
|
||||
print_result(
|
||||
{
|
||||
"status": "unavailable",
|
||||
"method": "gh",
|
||||
"repo": DEFAULT_REPO,
|
||||
"message": f"GitHub CLI is not installed or not authenticated for {DEFAULT_HOSTNAME}.",
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
issue_url, gh_error = create_issue_with_gh(gh_path, title, body)
|
||||
if issue_url is not None:
|
||||
print_result(
|
||||
{
|
||||
"status": "created",
|
||||
"method": "gh",
|
||||
"repo": DEFAULT_REPO,
|
||||
"issue_url": issue_url,
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
print_result(
|
||||
{
|
||||
"status": "failed",
|
||||
"method": "gh",
|
||||
"repo": DEFAULT_REPO,
|
||||
"gh_error": gh_error,
|
||||
}
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
title = normalize_title(args.title)
|
||||
body = normalize_body(read_text(args.body_file, "body"))
|
||||
|
||||
if args.use_method == "browser":
|
||||
return fallback_to_browser(title, body)
|
||||
# argparse enforces choices=["gh", "browser"], so the remaining case is "gh".
|
||||
return file_with_gh(title, body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def linux_info() -> dict[str, str]:
|
||||
fields: dict[str, str] = {}
|
||||
os_release = Path("/etc/os-release")
|
||||
if os_release.exists():
|
||||
for line in os_release.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
fields[key] = value.strip().strip(chr(34))
|
||||
|
||||
return {
|
||||
"os": fields.get("PRETTY_NAME") or fields.get("NAME") or "Linux",
|
||||
"os_version": fields.get("VERSION_ID") or fields.get("VERSION") or platform.release(),
|
||||
"kernel": platform.release(),
|
||||
}
|
||||
|
||||
|
||||
def mac_info() -> dict[str, str]:
|
||||
build = None
|
||||
result = subprocess.run(
|
||||
["sw_vers", "-buildVersion"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
build = result.stdout.strip() or None
|
||||
|
||||
return {
|
||||
"os": "macOS",
|
||||
"os_version": platform.mac_ver()[0] or None,
|
||||
"os_build": build,
|
||||
}
|
||||
|
||||
|
||||
def windows_info() -> dict[str, str]:
|
||||
version = sys.getwindowsversion()
|
||||
return {
|
||||
"os": "Windows",
|
||||
"os_version": f"{version.major}.{version.minor}.{version.build}",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
info = mac_info()
|
||||
elif system == "Linux":
|
||||
info = linux_info()
|
||||
elif system == "Windows":
|
||||
info = windows_info()
|
||||
else:
|
||||
info = {
|
||||
"os": system or "Unknown",
|
||||
"os_version": platform.version() or None,
|
||||
}
|
||||
|
||||
json.dump({k: v for k, v in info.items() if v}, sys.stdout, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for file_feedback_issue.py.
|
||||
|
||||
Run with:
|
||||
python3 resources/channel-gated-skills/dogfood/feedback/scripts/test_file_feedback_issue.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
MODULE_PATH = SCRIPT_DIR / "file_feedback_issue.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("file_feedback_issue", MODULE_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
ffi = load_module()
|
||||
|
||||
|
||||
class BuildNewIssueUrlTests(unittest.TestCase):
|
||||
def test_url_includes_repo_and_title(self):
|
||||
url = ffi.build_new_issue_url("Hello world", None)
|
||||
self.assertTrue(url.startswith(f"https://{ffi.DEFAULT_HOSTNAME}/{ffi.DEFAULT_REPO}/issues/new?"))
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["Hello world"])
|
||||
self.assertNotIn("body", qs)
|
||||
|
||||
def test_url_includes_body_when_provided(self):
|
||||
url = ffi.build_new_issue_url("T", "body text with spaces & symbols?")
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["T"])
|
||||
self.assertEqual(qs["body"], ["body text with spaces & symbols?"])
|
||||
|
||||
def test_special_characters_are_percent_encoded(self):
|
||||
url = ffi.build_new_issue_url("crash: `foo`", "<script>alert(1)</script>")
|
||||
# Spaces should be %20, not +, because we use quote_via=quote.
|
||||
self.assertIn("%20", url)
|
||||
self.assertNotIn("+", url.split("?", 1)[1])
|
||||
# Angle brackets and backticks are percent-encoded.
|
||||
self.assertIn("%3C", url)
|
||||
self.assertIn("%3E", url)
|
||||
|
||||
|
||||
class FallbackToBrowserTests(unittest.TestCase):
|
||||
def _run_fallback(
|
||||
self,
|
||||
title,
|
||||
body,
|
||||
open_ok=True,
|
||||
browser_available=(True, None),
|
||||
gh_path=None,
|
||||
gh_create_result=(None, "gh not configured"),
|
||||
):
|
||||
with mock.patch.object(
|
||||
ffi, "open_in_browser", return_value=open_ok
|
||||
) as open_mock, mock.patch.object(
|
||||
ffi, "browser_is_available", return_value=browser_available
|
||||
), mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated", return_value=gh_path
|
||||
), mock.patch.object(
|
||||
ffi, "create_issue_with_gh", return_value=gh_create_result
|
||||
):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = ffi.fallback_to_browser(title, body)
|
||||
payload = json.loads(buf.getvalue().strip())
|
||||
return rc, payload, open_mock
|
||||
|
||||
def test_short_body_is_embedded_in_url(self):
|
||||
rc, payload, open_mock = self._run_fallback("t", "small body")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "browser_opened")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
# Short bodies fit in the URL; no `body` field is surfaced separately.
|
||||
self.assertNotIn("body", payload)
|
||||
open_mock.assert_called_once()
|
||||
called_url = open_mock.call_args.args[0]
|
||||
parsed = urllib.parse.urlparse(called_url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["t"])
|
||||
self.assertEqual(qs["body"], ["small body"])
|
||||
|
||||
def test_long_body_surfaces_body_in_payload_and_opens_title_only_url(self):
|
||||
huge_body = "A" * (ffi.MAX_PREFILL_URL_LENGTH + 500)
|
||||
rc, payload, open_mock = self._run_fallback("t", huge_body)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "browser_opened")
|
||||
# Long bodies are returned in the payload so the caller can instruct
|
||||
# the user to paste them; no clipboard copy happens.
|
||||
self.assertEqual(payload["body"], huge_body)
|
||||
called_url = open_mock.call_args.args[0]
|
||||
parsed = urllib.parse.urlparse(called_url)
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
self.assertEqual(qs["title"], ["t"])
|
||||
self.assertNotIn("body", qs)
|
||||
|
||||
def test_browser_failure_falls_back_to_gh_when_available(self):
|
||||
rc, payload, _ = self._run_fallback(
|
||||
"t",
|
||||
"small body",
|
||||
open_ok=False,
|
||||
gh_path="/usr/bin/gh",
|
||||
gh_create_result=("https://github.com/warpdotdev/warp/issues/7", None),
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/7"))
|
||||
self.assertTrue(payload.get("browser_unavailable"))
|
||||
self.assertIn("attachment", payload["message"].lower())
|
||||
|
||||
def test_browser_failure_reports_failed_when_gh_also_unavailable(self):
|
||||
rc, payload, open_mock = self._run_fallback("t", "small body", open_ok=False)
|
||||
# Filing failed, so exit code must be non-zero for shell callers that
|
||||
# check `$?` to distinguish "issue filed" from "issue not filed".
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
self.assertIn("url", payload)
|
||||
# Failure messaging must acknowledge attachments so the user understands
|
||||
# why the image workflow couldn't complete.
|
||||
self.assertIn("attachment", payload["error"].lower())
|
||||
|
||||
def test_browser_unavailable_falls_back_to_gh_when_available(self):
|
||||
rc, payload, open_mock = self._run_fallback(
|
||||
"t",
|
||||
"small body",
|
||||
browser_available=(False, "No DISPLAY"),
|
||||
gh_path="/usr/bin/gh",
|
||||
gh_create_result=("https://github.com/warpdotdev/warp/issues/8", None),
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/8"))
|
||||
self.assertTrue(payload.get("browser_unavailable"))
|
||||
self.assertIn("No DISPLAY", payload["message"])
|
||||
self.assertIn("attachment", payload["message"].lower())
|
||||
open_mock.assert_not_called()
|
||||
|
||||
def test_browser_unavailable_reports_failed_when_gh_also_unavailable(self):
|
||||
rc, payload, open_mock = self._run_fallback(
|
||||
"t",
|
||||
"small body",
|
||||
browser_available=(False, "No DISPLAY"),
|
||||
)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
self.assertIn("No DISPLAY", payload["error"])
|
||||
self.assertIn("attachment", payload["error"].lower())
|
||||
open_mock.assert_not_called()
|
||||
|
||||
|
||||
class BrowserIsAvailableTests(unittest.TestCase):
|
||||
def test_darwin_is_always_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Darwin"), \
|
||||
mock.patch.dict(ffi.os.environ, {}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_windows_is_always_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Windows"), \
|
||||
mock.patch.dict(ffi.os.environ, {}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_linux_without_display_is_unavailable(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Linux"), \
|
||||
mock.patch.dict(ffi.os.environ, {}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertFalse(ok)
|
||||
self.assertIsNotNone(reason)
|
||||
|
||||
def test_linux_with_display_is_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Linux"), \
|
||||
mock.patch.dict(ffi.os.environ, {"DISPLAY": ":0"}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_linux_with_wayland_display_is_available(self):
|
||||
with mock.patch.object(ffi.platform, "system", return_value="Linux"), \
|
||||
mock.patch.dict(ffi.os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
|
||||
ok, reason = ffi.browser_is_available()
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
|
||||
class FileWithGhTests(unittest.TestCase):
|
||||
def _run(self, **mocks):
|
||||
patches = []
|
||||
for attr, value in mocks.items():
|
||||
patches.append(mock.patch.object(ffi, attr, return_value=value))
|
||||
buf = io.StringIO()
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
with redirect_stdout(buf):
|
||||
rc = ffi.file_with_gh("hello", "body")
|
||||
return rc, json.loads(buf.getvalue().strip())
|
||||
|
||||
def test_reports_unavailable_when_gh_missing(self):
|
||||
rc, payload = self._run(gh_path_if_authenticated=None)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "unavailable")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertIn("message", payload)
|
||||
|
||||
def test_creates_when_gh_available(self):
|
||||
rc, payload = self._run(
|
||||
gh_path_if_authenticated="/usr/bin/gh",
|
||||
create_issue_with_gh=(
|
||||
"https://github.com/warpdotdev/warp/issues/42",
|
||||
None,
|
||||
),
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/42"))
|
||||
|
||||
def test_reports_failed_when_gh_create_fails(self):
|
||||
rc, payload = self._run(
|
||||
gh_path_if_authenticated="/usr/bin/gh",
|
||||
create_issue_with_gh=(None, "gh failed to create issue"),
|
||||
)
|
||||
# Filing failed, so exit code must be non-zero.
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertEqual(payload["gh_error"], "gh failed to create issue")
|
||||
|
||||
|
||||
class MainTests(unittest.TestCase):
|
||||
"""Tests for the --use dispatch in main().
|
||||
|
||||
The caller must pick a filing method explicitly; main() does not fall back
|
||||
between the two paths.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.body_file = SCRIPT_DIR / "_tmp_body.txt"
|
||||
self.body_file.write_text("my body", encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self.body_file.unlink(missing_ok=True)
|
||||
|
||||
def _run_main(self, argv_extras):
|
||||
argv = [
|
||||
"file_feedback_issue.py",
|
||||
"--title",
|
||||
"hello",
|
||||
"--body-file",
|
||||
str(self.body_file),
|
||||
*argv_extras,
|
||||
]
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with redirect_stdout(buf):
|
||||
rc = ffi.main()
|
||||
return rc, json.loads(buf.getvalue().strip())
|
||||
|
||||
def test_use_gh_creates_when_gh_available(self):
|
||||
with mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated", return_value="/usr/bin/gh"
|
||||
), mock.patch.object(
|
||||
ffi,
|
||||
"create_issue_with_gh",
|
||||
return_value=(
|
||||
"https://github.com/warpdotdev/warp/issues/999",
|
||||
None,
|
||||
),
|
||||
):
|
||||
rc, payload = self._run_main(["--use", "gh"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "created")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
self.assertTrue(payload["issue_url"].endswith("/issues/999"))
|
||||
|
||||
def test_use_gh_reports_unavailable_and_does_not_open_browser(self):
|
||||
with mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated", return_value=None
|
||||
), mock.patch.object(ffi, "open_in_browser", return_value=True) as open_mock:
|
||||
rc, payload = self._run_main(["--use", "gh"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "unavailable")
|
||||
self.assertEqual(payload["method"], "gh")
|
||||
# Critical invariant: --use gh must not silently fall back to the browser.
|
||||
open_mock.assert_not_called()
|
||||
|
||||
def test_use_browser_does_not_touch_gh(self):
|
||||
with mock.patch.object(
|
||||
ffi, "gh_path_if_authenticated"
|
||||
) as gh_mock, mock.patch.object(
|
||||
ffi, "create_issue_with_gh"
|
||||
) as create_mock, mock.patch.object(
|
||||
ffi, "browser_is_available", return_value=(True, None)
|
||||
), mock.patch.object(ffi, "open_in_browser", return_value=True) as open_mock:
|
||||
rc, payload = self._run_main(["--use", "browser"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(payload["status"], "browser_opened")
|
||||
self.assertEqual(payload["method"], "browser")
|
||||
open_mock.assert_called_once()
|
||||
gh_mock.assert_not_called()
|
||||
create_mock.assert_not_called()
|
||||
|
||||
def test_missing_use_flag_exits_with_argparse_error(self):
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"file_feedback_issue.py",
|
||||
"--title",
|
||||
"hello",
|
||||
"--body-file",
|
||||
str(self.body_file),
|
||||
],
|
||||
), mock.patch.object(sys, "stderr", stderr), contextlib.suppress(SystemExit):
|
||||
ffi.main()
|
||||
self.assertIn("--use", stderr.getvalue())
|
||||
|
||||
def test_invalid_use_value_is_rejected(self):
|
||||
stderr = io.StringIO()
|
||||
with mock.patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"file_feedback_issue.py",
|
||||
"--use",
|
||||
"carrier-pigeon",
|
||||
"--title",
|
||||
"hello",
|
||||
"--body-file",
|
||||
str(self.body_file),
|
||||
],
|
||||
), mock.patch.object(sys, "stderr", stderr), contextlib.suppress(SystemExit):
|
||||
ffi.main()
|
||||
self.assertIn("invalid choice", stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user