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.
- node_mantis-0.1.0a1.dist-info/METADATA +44 -0
- node_mantis-0.1.0a1.dist-info/RECORD +16 -0
- node_mantis-0.1.0a1.dist-info/WHEEL +5 -0
- node_mantis-0.1.0a1.dist-info/entry_points.txt +2 -0
- node_mantis-0.1.0a1.dist-info/licenses/LICENSE.txt +18 -0
- node_mantis-0.1.0a1.dist-info/top_level.txt +1 -0
- nodemantis/__init__.py +17 -0
- nodemantis/__main__.py +4 -0
- nodemantis/browser.py +69 -0
- nodemantis/cli.py +147 -0
- nodemantis/config.py +135 -0
- nodemantis/fixtures/smoke.html +22 -0
- nodemantis/py.typed +1 -0
- nodemantis/runtime.py +75 -0
- nodemantis/runtime_broker.py +25 -0
- nodemantis/smoke.py +148 -0
|
@@ -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,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
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"
|