node-mantis 0.1.0a1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of node-mantis might be problematic. Click here for more details.

@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: node-mantis
3
+ Version: 0.1.0a1
4
+ Summary: NodeMantis Python SDK and source-free install smoke CLI
5
+ Author: NodeMantis
6
+ License-Expression: LicenseRef-NodeMantis-Proprietary
7
+ Project-URL: Homepage, https://github.com/Node-Mantis/CleanSlate#readme
8
+ Project-URL: Repository, https://github.com/Node-Mantis/CleanSlate
9
+ Project-URL: Issues, https://github.com/Node-Mantis/CleanSlate/issues
10
+ Keywords: browser,automation,playwright,node-mantis,nodemantis
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE.txt
21
+ Provides-Extra: browser
22
+ Requires-Dist: playwright>=1.59.1; extra == "browser"
23
+ Dynamic: license-file
24
+
25
+ # NodeMantis Python
26
+
27
+ Alpha Python SDK and source-free smoke CLI for NodeMantis.
28
+
29
+ Customer install:
30
+
31
+ ```bash
32
+ pip install node-mantis
33
+ pip install "node-mantis[browser]"
34
+ python -m nodemantis configure --api-key "$NODEMANTIS_API_KEY"
35
+ python -m nodemantis smoke
36
+ ```
37
+
38
+ `node-mantis[browser]` installs Playwright for SDK-managed browser checks and
39
+ smoke tests.
40
+
41
+ Supported alpha platforms: Linux x64, Windows x64, macOS x64, and macOS arm64.
42
+
43
+ This package is proprietary alpha software. Use is governed by `LICENSE.txt`
44
+ and applicable NodeMantis terms.
@@ -0,0 +1,16 @@
1
+ node_mantis-0.1.0a1.dist-info/licenses/LICENSE.txt,sha256=obNmXb8HRVRK9NB5-M-zQLEsxypU5FBcBUzOChWKpbo,920
2
+ nodemantis/__init__.py,sha256=bxihFLQm85R93Co76qmYt2d6NOLjwQf5WZS5g3zSURI,469
3
+ nodemantis/__main__.py,sha256=0iDYjdmiXNBinFF23WUyotsdpA6wFKjoB5aw3esTSRg,83
4
+ nodemantis/browser.py,sha256=yCwczEnDe0t7T61w-TrWnwSs-b0qc15Pv8QsT-9zUXw,2485
5
+ nodemantis/cli.py,sha256=gnBxDS3rv_LoZLMMrEhUdpdUkr22W6wHDmz2LEA1U0w,5960
6
+ nodemantis/config.py,sha256=bsM3FvVoMP1kK8qxK0T6ET2rS-hn5fbQRvFioKHs2Rw,4493
7
+ nodemantis/py.typed,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
8
+ nodemantis/runtime.py,sha256=MCkAXvIP4j0NREYp7df3DUfok5W9Q0lSNlu8jv9rTCo,2239
9
+ nodemantis/runtime_broker.py,sha256=EdqAdkKwqXRrlMwHiGUwPmTyFFjhv_rVGMMtcv7bKqU,588
10
+ nodemantis/smoke.py,sha256=B6HcKWYjhBJFcoHTSF4HRQZNowhwjxE6mmiI6V7JHow,5049
11
+ nodemantis/fixtures/smoke.html,sha256=ulix-qaiBCHqGpMJRI9OmANt01lhEMFDASgs-2O1kz0,690
12
+ node_mantis-0.1.0a1.dist-info/METADATA,sha256=6WV_HBommm5C6MmGZnzKsijyxEPxAhGa6LsLWKQNR5w,1575
13
+ node_mantis-0.1.0a1.dist-info/WHEEL,sha256=hVx9elvUDfBjRmbl8JwIcXXikst35RZTZK9nspfI_28,98
14
+ node_mantis-0.1.0a1.dist-info/entry_points.txt,sha256=iB3hkGF-CFZaPeeUadgxrljZqJV2FRCIClWIJGEK-hg,51
15
+ node_mantis-0.1.0a1.dist-info/top_level.txt,sha256=9kaSA3eSBIApvJUTG4Ni6h42DcfJebMhccQj_tS9Bu4,11
16
+ node_mantis-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nodemantis = nodemantis.cli:main
@@ -0,0 +1,18 @@
1
+ NodeMantis Alpha Proprietary License
2
+
3
+ Copyright (c) NodeMantis. All rights reserved.
4
+
5
+ This alpha package is proprietary software. You may install and use it only to
6
+ evaluate or access NodeMantis services under an active NodeMantis account,
7
+ agreement, or written authorization from NodeMantis.
8
+
9
+ You may not copy, redistribute, sublicense, sell, rent, lease, host for third
10
+ parties, or provide this software as a managed service without prior written
11
+ permission from NodeMantis. You may not reverse engineer, decompile, disassemble,
12
+ or attempt to derive source code from this software except to the extent that
13
+ applicable law prohibits this restriction.
14
+
15
+ This alpha software is provided "as is" without warranties of any kind. It may
16
+ change, break compatibility, or be withdrawn at any time. To the maximum extent
17
+ permitted by law, NodeMantis is not liable for damages arising from use of this
18
+ software.
@@ -0,0 +1 @@
1
+ nodemantis
nodemantis/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """NodeMantis Python SDK install and smoke-test helpers."""
2
+
3
+ from .config import DEFAULT_BACKEND_URL, ResolvedConfig, resolve_config
4
+ from .runtime import UnsupportedPlatformError, runtime_platform_info
5
+ from .smoke import SmokeResult, run_smoke
6
+
7
+ __all__ = [
8
+ "DEFAULT_BACKEND_URL",
9
+ "ResolvedConfig",
10
+ "SmokeResult",
11
+ "UnsupportedPlatformError",
12
+ "resolve_config",
13
+ "run_smoke",
14
+ "runtime_platform_info",
15
+ ]
16
+
17
+ __version__ = "0.1.0a1"
nodemantis/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
nodemantis/browser.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ BrowserName = Literal["chrome", "chromium"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BrowserCheckResult:
13
+ browser: BrowserName
14
+ available: bool
15
+ error: str | None = None
16
+
17
+
18
+ def normalize_browser(value: str | None) -> BrowserName:
19
+ if value in (None, "", "chrome"):
20
+ return "chrome"
21
+ if value == "chromium":
22
+ return "chromium"
23
+ raise ValueError(f'Unsupported browser "{value}". Use "chrome" or "chromium".')
24
+
25
+
26
+ def check_browser(browser: BrowserName = "chrome") -> BrowserCheckResult:
27
+ try:
28
+ from playwright.sync_api import sync_playwright
29
+ except ImportError as exc:
30
+ return BrowserCheckResult(
31
+ browser=browser,
32
+ available=False,
33
+ error=f"Playwright is not installed: {exc}",
34
+ )
35
+
36
+ try:
37
+ with sync_playwright() as p:
38
+ launch_options = {"headless": True}
39
+ if browser == "chrome":
40
+ launch_options["channel"] = "chrome"
41
+ launched = p.chromium.launch(**launch_options)
42
+ launched.close()
43
+ return BrowserCheckResult(browser=browser, available=True)
44
+ except Exception as exc: # noqa: BLE001 - surfaced as a user-facing diagnostic.
45
+ return BrowserCheckResult(browser=browser, available=False, error=str(exc))
46
+
47
+
48
+ def install_browser(browser: BrowserName = "chrome") -> None:
49
+ subprocess.check_call([sys.executable, "-m", "playwright", "install", browser])
50
+
51
+
52
+ def browser_install_command(browser: BrowserName = "chrome") -> str:
53
+ if browser == "chrome":
54
+ return "python -m nodemantis install-browser"
55
+ return "python -m nodemantis install-browser --browser chromium"
56
+
57
+
58
+ def missing_browser_message(browser: BrowserName = "chrome", details: str | None = None) -> str:
59
+ label = "Google Chrome" if browser == "chrome" else "Chromium"
60
+ lines = [
61
+ f"{label} is not installed or is not launchable by Playwright.",
62
+ "Install Playwright browser support with: pip install 'node-mantis[browser]'",
63
+ f"Install the browser with: {browser_install_command(browser)}",
64
+ ]
65
+ if browser == "chrome":
66
+ lines.append("For CI fallback, run smoke with --browser chromium after installing Chromium.")
67
+ if details:
68
+ lines.extend(["", "Playwright error:", details])
69
+ return "\n".join(lines)
nodemantis/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from .browser import check_browser, install_browser, missing_browser_message, normalize_browser
8
+ from .config import DEFAULT_BACKEND_URL, ConfigFile, mask_secret, read_config_file, resolve_config, write_config_file
9
+ from .runtime import broker_hello, runtime_platform_info
10
+ from .smoke import run_smoke
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(prog="nodemantis")
15
+ parser.add_argument("--version", action="version", version="nodemantis 0.1.0a1")
16
+ subparsers = parser.add_subparsers(dest="command", required=True)
17
+
18
+ configure = subparsers.add_parser("configure", help="write NodeMantis API credentials")
19
+ configure.add_argument("--api-key")
20
+ configure.add_argument("--auth-token")
21
+ configure.add_argument("--backend-url", default=DEFAULT_BACKEND_URL)
22
+ configure.add_argument("--config")
23
+ configure.set_defaults(func=_configure)
24
+
25
+ doctor = subparsers.add_parser("doctor", help="check config, platform runtime, and browser availability")
26
+ doctor.add_argument("--api-key")
27
+ doctor.add_argument("--backend-url")
28
+ doctor.add_argument("--browser", default="chrome")
29
+ doctor.add_argument("--json", action="store_true")
30
+ doctor.set_defaults(func=_doctor)
31
+
32
+ install = subparsers.add_parser("install-browser", help="install Google Chrome or Chromium")
33
+ install.add_argument("--browser", default="chrome")
34
+ install.set_defaults(func=_install_browser)
35
+
36
+ smoke = subparsers.add_parser("smoke", help="run a source-free customer install smoke test")
37
+ smoke.add_argument("--api-key")
38
+ smoke.add_argument("--backend-url")
39
+ smoke.add_argument("--browser", default="chrome")
40
+ smoke.add_argument("--install-browser", action="store_true")
41
+ smoke.add_argument("--headed", action="store_true")
42
+ smoke.add_argument("--skip-backend", action="store_true", help="skip backend probe; intended for package tests only")
43
+ smoke.set_defaults(func=_smoke)
44
+
45
+ args = parser.parse_args(argv)
46
+ try:
47
+ return int(args.func(args) or 0)
48
+ except Exception as exc: # noqa: BLE001 - CLI boundary.
49
+ print(str(exc), file=sys.stderr)
50
+ return 1
51
+
52
+
53
+ def _configure(args: argparse.Namespace) -> int:
54
+ existing = read_config_file(args.config)
55
+ credential = args.api_key or args.auth_token
56
+ if not credential and sys.stdin.isatty():
57
+ credential = input("NodeMantis API key: ").strip()
58
+
59
+ if args.api_key:
60
+ next_config = ConfigFile(api_key=args.api_key, auth_token=None, backend_url=args.backend_url or existing.backend_url)
61
+ elif args.auth_token:
62
+ next_config = ConfigFile(api_key=None, auth_token=args.auth_token, backend_url=args.backend_url or existing.backend_url)
63
+ elif credential:
64
+ next_config = ConfigFile(api_key=credential, auth_token=None, backend_url=args.backend_url or existing.backend_url)
65
+ else:
66
+ next_config = ConfigFile(
67
+ api_key=existing.api_key,
68
+ auth_token=existing.auth_token,
69
+ backend_url=args.backend_url or existing.backend_url,
70
+ )
71
+
72
+ path = write_config_file(next_config, args.config)
73
+ resolved = resolve_config(config_path=path)
74
+ print(f"Wrote NodeMantis config: {resolved.config_path}")
75
+ print(f"Backend: {resolved.backend_url}")
76
+ print(f"API key: {mask_secret(resolved.api_key)}")
77
+ return 0
78
+
79
+
80
+ def _doctor(args: argparse.Namespace) -> int:
81
+ browser = normalize_browser(args.browser)
82
+ resolved = resolve_config(api_key=args.api_key, backend_url=args.backend_url)
83
+ report: dict[str, object] = {
84
+ "configPath": str(resolved.config_path),
85
+ "backendUrl": resolved.backend_url,
86
+ "backendUrlSource": resolved.backend_url_source,
87
+ "apiKeySource": resolved.api_key_source or "missing",
88
+ "apiKey": mask_secret(resolved.api_key),
89
+ }
90
+
91
+ try:
92
+ report["platform"] = runtime_platform_info().__dict__
93
+ report["runtime"] = broker_hello()
94
+ except Exception as exc: # noqa: BLE001
95
+ report["runtimeError"] = str(exc)
96
+
97
+ browser_check = check_browser(browser)
98
+ report["browser"] = {
99
+ "name": browser,
100
+ "available": browser_check.available,
101
+ "error": browser_check.error,
102
+ }
103
+
104
+ if args.json:
105
+ print(json.dumps(report, indent=2))
106
+ return 0
107
+
108
+ print(f"Config: {report['configPath']}")
109
+ print(f"Backend: {resolved.backend_url} ({resolved.backend_url_source})")
110
+ source = f" ({resolved.api_key_source})" if resolved.api_key_source else ""
111
+ print(f"API key: {mask_secret(resolved.api_key)}{source}")
112
+ if "runtimeError" in report:
113
+ print(f"Runtime: failed - {report['runtimeError']}")
114
+ else:
115
+ print(f"Runtime: ok ({report['platform']})")
116
+ print(f"Browser: {'ok' if browser_check.available else 'missing'} ({browser})")
117
+ if not browser_check.available:
118
+ print(missing_browser_message(browser))
119
+ return 0
120
+
121
+
122
+ def _install_browser(args: argparse.Namespace) -> int:
123
+ browser = normalize_browser(args.browser)
124
+ install_browser(browser)
125
+ print(f"Installed {'Google Chrome' if browser == 'chrome' else 'Chromium'} for NodeMantis.")
126
+ return 0
127
+
128
+
129
+ def _smoke(args: argparse.Namespace) -> int:
130
+ result = run_smoke(
131
+ api_key=args.api_key,
132
+ backend_url=args.backend_url,
133
+ browser=args.browser,
134
+ install_browser=args.install_browser,
135
+ headless=not args.headed,
136
+ skip_backend=args.skip_backend,
137
+ )
138
+ print("NodeMantis smoke passed.")
139
+ print(f"Backend: {result.backend_url if result.backend_checked else 'skipped'}")
140
+ print(f"Runtime: {result.runtime_platform}")
141
+ print(f"Browser: {result.browser}")
142
+ print(f"Assertion: {result.assertion}")
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
nodemantis/config.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Mapping, Optional
9
+
10
+ DEFAULT_BACKEND_URL = "https://api.nodemantis.com"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ConfigFile:
15
+ api_key: Optional[str] = None
16
+ auth_token: Optional[str] = None
17
+ backend_url: Optional[str] = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ResolvedConfig:
22
+ api_key: Optional[str]
23
+ api_key_source: Optional[str]
24
+ backend_url: str
25
+ backend_url_source: str
26
+ config_path: Path
27
+ file_config: ConfigFile
28
+
29
+
30
+ def get_config_path(env: Mapping[str, str] | None = None) -> Path:
31
+ env = os.environ if env is None else env
32
+ override = _clean(env.get("NODEMANTIS_CONFIG_FILE"))
33
+ if override:
34
+ return Path(override)
35
+
36
+ home = Path.home()
37
+ system = platform.system().lower()
38
+ if system == "windows":
39
+ return Path(env.get("APPDATA", home / "AppData" / "Roaming")) / "nodemantis" / "config.json"
40
+ if system == "darwin":
41
+ return home / "Library" / "Application Support" / "nodemantis" / "config.json"
42
+ return Path(env.get("XDG_CONFIG_HOME", home / ".config")) / "nodemantis" / "config.json"
43
+
44
+
45
+ def read_config_file(config_path: Path | str | None = None) -> ConfigFile:
46
+ path = Path(config_path) if config_path else get_config_path()
47
+ if not path.exists():
48
+ return ConfigFile()
49
+
50
+ data = json.loads(path.read_text(encoding="utf-8") or "{}")
51
+ return ConfigFile(
52
+ api_key=_clean(data.get("apiKey") or data.get("api_key")),
53
+ auth_token=_clean(data.get("authToken") or data.get("auth_token")),
54
+ backend_url=_clean(data.get("backendUrl") or data.get("backend_url")),
55
+ )
56
+
57
+
58
+ def write_config_file(config: ConfigFile, config_path: Path | str | None = None) -> Path:
59
+ path = Path(config_path) if config_path else get_config_path()
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ data = {
62
+ "apiKey": config.api_key,
63
+ "authToken": config.auth_token,
64
+ "backendUrl": config.backend_url,
65
+ }
66
+ path.write_text(json.dumps({k: v for k, v in data.items() if v}, indent=2) + "\n", encoding="utf-8")
67
+ try:
68
+ path.chmod(0o600)
69
+ except OSError:
70
+ pass
71
+ return path
72
+
73
+
74
+ def resolve_config(
75
+ *,
76
+ api_key: str | None = None,
77
+ backend_url: str | None = None,
78
+ config_path: Path | str | None = None,
79
+ env: Mapping[str, str] | None = None,
80
+ ) -> ResolvedConfig:
81
+ env = os.environ if env is None else env
82
+ path = Path(config_path) if config_path else get_config_path(env)
83
+ file_config = read_config_file(path)
84
+
85
+ explicit_key = _clean(api_key)
86
+ env_api_key = _clean(env.get("NODEMANTIS_API_KEY"))
87
+ env_auth_token = _clean(env.get("NODEMANTIS_AUTH_TOKEN"))
88
+ file_key = _clean(file_config.api_key) or _clean(file_config.auth_token)
89
+
90
+ if explicit_key:
91
+ resolved_key, key_source = explicit_key, "explicit"
92
+ elif env_api_key:
93
+ resolved_key, key_source = env_api_key, "NODEMANTIS_API_KEY"
94
+ elif env_auth_token:
95
+ resolved_key, key_source = env_auth_token, "NODEMANTIS_AUTH_TOKEN"
96
+ elif file_key:
97
+ resolved_key, key_source = file_key, "config"
98
+ else:
99
+ resolved_key, key_source = None, None
100
+
101
+ explicit_backend = _clean(backend_url)
102
+ env_backend = _clean(env.get("NODEMANTIS_BACKEND_URL"))
103
+ file_backend = _clean(file_config.backend_url)
104
+ if explicit_backend:
105
+ resolved_backend, backend_source = explicit_backend, "explicit"
106
+ elif env_backend:
107
+ resolved_backend, backend_source = env_backend, "NODEMANTIS_BACKEND_URL"
108
+ elif file_backend:
109
+ resolved_backend, backend_source = file_backend, "config"
110
+ else:
111
+ resolved_backend, backend_source = DEFAULT_BACKEND_URL, "default"
112
+
113
+ return ResolvedConfig(
114
+ api_key=resolved_key,
115
+ api_key_source=key_source,
116
+ backend_url=resolved_backend,
117
+ backend_url_source=backend_source,
118
+ config_path=path,
119
+ file_config=file_config,
120
+ )
121
+
122
+
123
+ def mask_secret(secret: str | None) -> str:
124
+ if not secret:
125
+ return "not configured"
126
+ if len(secret) <= 8:
127
+ return "configured"
128
+ return f"{secret[:4]}...{secret[-4:]}"
129
+
130
+
131
+ def _clean(value: object) -> Optional[str]:
132
+ if not isinstance(value, str):
133
+ return None
134
+ stripped = value.strip()
135
+ return stripped or None
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>NodeMantis Smoke Fixture</title>
6
+ <style>
7
+ body { font-family: system-ui, sans-serif; margin: 40px; }
8
+ button { font: inherit; padding: 8px 12px; }
9
+ #status { margin-top: 16px; font-weight: 700; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <h1>NodeMantis Smoke Fixture</h1>
14
+ <button id="complete" type="button">Complete fixture</button>
15
+ <p id="status">waiting</p>
16
+ <script>
17
+ document.getElementById("complete").addEventListener("click", () => {
18
+ document.getElementById("status").textContent = "fixture success";
19
+ });
20
+ </script>
21
+ </body>
22
+ </html>
nodemantis/py.typed ADDED
@@ -0,0 +1 @@
1
+
nodemantis/runtime.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RuntimePlatformInfo:
13
+ os_name: str
14
+ arch: str
15
+ tag: str
16
+
17
+
18
+ SUPPORTED_PLATFORMS = {
19
+ ("linux", "x64"): "linux-x64",
20
+ ("windows", "x64"): "win32-x64",
21
+ ("darwin", "x64"): "darwin-x64",
22
+ ("darwin", "arm64"): "darwin-arm64",
23
+ }
24
+
25
+
26
+ class UnsupportedPlatformError(RuntimeError):
27
+ def __init__(self, os_name: str, arch: str) -> None:
28
+ super().__init__(
29
+ "NodeMantis v1 supports Linux x64, Windows x64, macOS x64, and macOS arm64. "
30
+ f"Current platform is {os_name}/{arch}."
31
+ )
32
+ self.os_name = os_name
33
+ self.arch = arch
34
+
35
+
36
+ def runtime_platform_info(
37
+ os_name: str | None = None,
38
+ machine: str | None = None,
39
+ ) -> RuntimePlatformInfo:
40
+ normalized_os = _normalize_os(os_name or platform.system())
41
+ normalized_arch = _normalize_arch(machine or platform.machine())
42
+ tag = SUPPORTED_PLATFORMS.get((normalized_os, normalized_arch))
43
+ if not tag:
44
+ raise UnsupportedPlatformError(normalized_os, normalized_arch)
45
+ return RuntimePlatformInfo(os_name=normalized_os, arch=normalized_arch, tag=tag)
46
+
47
+
48
+ def broker_hello() -> dict[str, str]:
49
+ runtime_platform_info()
50
+ runtime_path = os.environ.get("NODEMANTIS_RUNTIME_PATH")
51
+ command = [runtime_path, "hello"] if runtime_path else [sys.executable, "-m", "nodemantis.runtime_broker", "hello"]
52
+ result = subprocess.run(command, check=True, capture_output=True, text=True)
53
+ return json.loads(result.stdout)
54
+
55
+
56
+ def _normalize_os(value: str) -> str:
57
+ lowered = value.lower()
58
+ if lowered.startswith("msys") or lowered.startswith("mingw"):
59
+ return "windows"
60
+ if lowered == "windows":
61
+ return "windows"
62
+ if lowered == "darwin":
63
+ return "darwin"
64
+ if lowered == "linux":
65
+ return "linux"
66
+ return lowered
67
+
68
+
69
+ def _normalize_arch(value: str) -> str:
70
+ lowered = value.lower()
71
+ if lowered in {"x86_64", "amd64", "x64"}:
72
+ return "x64"
73
+ if lowered in {"arm64", "aarch64"}:
74
+ return "arm64"
75
+ return lowered
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from .runtime import runtime_platform_info
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> int:
10
+ argv = argv or sys.argv[1:]
11
+ if argv != ["hello"]:
12
+ print("usage: python -m nodemantis.runtime_broker hello", file=sys.stderr)
13
+ return 2
14
+
15
+ info = runtime_platform_info()
16
+ print(json.dumps({
17
+ "runtime": "nodemantis-python-broker",
18
+ "runtimeVersion": "0.1.0a1",
19
+ "platform": info.tag,
20
+ }))
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ raise SystemExit(main())
nodemantis/smoke.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from importlib import resources
8
+ from typing import Optional
9
+ from urllib.error import HTTPError
10
+ from urllib.request import Request, urlopen
11
+ from urllib.parse import urljoin
12
+
13
+ from .browser import (
14
+ BrowserName,
15
+ browser_install_command,
16
+ check_browser,
17
+ install_browser as install_browser_package,
18
+ missing_browser_message,
19
+ normalize_browser,
20
+ )
21
+ from .config import resolve_config
22
+ from .runtime import broker_hello, runtime_platform_info
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SmokeResult:
27
+ ok: bool
28
+ backend_url: str
29
+ backend_checked: bool
30
+ browser: BrowserName
31
+ runtime_platform: str
32
+ assertion: str
33
+
34
+
35
+ def run_smoke(
36
+ *,
37
+ api_key: str | None = None,
38
+ backend_url: str | None = None,
39
+ browser: str | None = "chrome",
40
+ install_browser: bool = False,
41
+ headless: bool = True,
42
+ skip_backend: bool = False,
43
+ timeout_seconds: int = 15,
44
+ ) -> SmokeResult:
45
+ browser_name = normalize_browser(browser)
46
+ config = resolve_config(api_key=api_key, backend_url=backend_url)
47
+ platform_info = runtime_platform_info()
48
+
49
+ if not config.api_key and not skip_backend:
50
+ raise RuntimeError(
51
+ "NodeMantis API key is not configured.\n"
52
+ "Set NODEMANTIS_API_KEY, NODEMANTIS_AUTH_TOKEN, or run: "
53
+ "python -m nodemantis configure --api-key <key>"
54
+ )
55
+
56
+ browser_check = check_browser(browser_name)
57
+ if not browser_check.available:
58
+ if install_browser:
59
+ install_browser_package(browser_name)
60
+ browser_check = check_browser(browser_name)
61
+ elif _is_interactive():
62
+ answer = input(
63
+ f"{'Google Chrome' if browser_name == 'chrome' else 'Chromium'} is missing. "
64
+ f"Install with {browser_install_command(browser_name)} now? [y/N] "
65
+ )
66
+ if answer.strip().lower() in {"y", "yes"}:
67
+ install_browser_package(browser_name)
68
+ browser_check = check_browser(browser_name)
69
+
70
+ if not browser_check.available:
71
+ raise RuntimeError(missing_browser_message(browser_name, browser_check.error))
72
+
73
+ broker_hello()
74
+
75
+ if not skip_backend:
76
+ _verify_backend_usage(
77
+ api_key=config.api_key or "",
78
+ backend_url=config.backend_url,
79
+ timeout_seconds=timeout_seconds,
80
+ )
81
+
82
+ _verify_fixture(browser_name, headless=headless)
83
+
84
+ return SmokeResult(
85
+ ok=True,
86
+ backend_url=config.backend_url,
87
+ backend_checked=not skip_backend,
88
+ browser=browser_name,
89
+ runtime_platform=platform_info.tag,
90
+ assertion="local fixture button changed status to fixture success",
91
+ )
92
+
93
+
94
+ def _verify_backend_usage(
95
+ *,
96
+ api_key: str,
97
+ backend_url: str,
98
+ timeout_seconds: int,
99
+ ) -> None:
100
+ url = urljoin(backend_url.rstrip("/") + "/", "v1/usage")
101
+ request = Request(
102
+ url,
103
+ method="GET",
104
+ headers={
105
+ "authorization": f"Bearer {api_key}",
106
+ },
107
+ )
108
+
109
+ try:
110
+ with urlopen(request, timeout=timeout_seconds) as response: # noqa: S310 - user-configured backend URL.
111
+ if response.status < 200 or response.status >= 300:
112
+ raise RuntimeError(f"NodeMantis backend usage check failed: HTTP {response.status}")
113
+ except HTTPError as exc:
114
+ body = exc.read(500).decode("utf-8", errors="replace")
115
+ raise RuntimeError(
116
+ f"NodeMantis backend usage check failed: HTTP {exc.code}{chr(10) + body if body else ''}"
117
+ ) from exc
118
+
119
+
120
+ def _verify_fixture(browser: BrowserName, *, headless: bool) -> None:
121
+ try:
122
+ from playwright.sync_api import sync_playwright
123
+ except ImportError as exc:
124
+ raise RuntimeError(
125
+ "Playwright is required for Python smoke. Install with: pip install 'node-mantis[browser]'"
126
+ ) from exc
127
+
128
+ html = resources.files("nodemantis.fixtures").joinpath("smoke.html").read_text(encoding="utf-8")
129
+ data_url = "data:text/html;base64," + base64.b64encode(html.encode("utf-8")).decode("ascii")
130
+
131
+ with sync_playwright() as p:
132
+ launch_options = {"headless": headless}
133
+ if browser == "chrome":
134
+ launch_options["channel"] = "chrome"
135
+ launched = p.chromium.launch(**launch_options)
136
+ try:
137
+ page = launched.new_page()
138
+ page.goto(data_url, wait_until="domcontentloaded")
139
+ page.locator("#complete").click()
140
+ status = page.locator("#status").inner_text(timeout=5000).strip()
141
+ if status != "fixture success":
142
+ raise RuntimeError(f'Smoke fixture assertion failed: expected "fixture success", got "{status}"')
143
+ finally:
144
+ launched.close()
145
+
146
+
147
+ def _is_interactive() -> bool:
148
+ return sys.stdin.isatty() and sys.stdout.isatty() and os.environ.get("CI") != "true"