push all scripts
This commit is contained in:
Generated
+18
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
|||||||
|
# SmartTime 9 UI integration
|
||||||
|
|
||||||
|
Observed read-only against the internal instance on 2026-09-25 using a separate
|
||||||
|
headless Playwright browser. No stamps, corrections or approvals were submitted.
|
||||||
|
The dashboard and September month row both showed **09:29**, not exactly 09:30.
|
||||||
|
|
||||||
|
## Changes from 8.6
|
||||||
|
|
||||||
|
- Login redirects to `/login`; the user input is now `input[name="username"]`,
|
||||||
|
not `useraccount`. The password input remains `input[name="password"]`.
|
||||||
|
- The application is Vaadin Flow with web components and shadow DOM, not the old
|
||||||
|
GWT dashboard. Setting `location.hash = 'dashboard_tab'` is obsolete.
|
||||||
|
- Sidebar items are `vaadin-side-nav-item[path="..."]`. Dashboard has an empty
|
||||||
|
path, month view has `MonthlyOV`; the selected item has a `current` attribute.
|
||||||
|
- The personal card still has a `Letzte Buchung` label followed by an HH:MM label.
|
||||||
|
It is inside `.shadow-xs`. Its `vaadin-icon[icon="far:building"]` uses the
|
||||||
|
status color. The phone icon is not the presence indicator.
|
||||||
|
- There is no `Aktueller Status` value. The status legend contains all four labels
|
||||||
|
regardless of actual presence. Matching the first `Anwesend` in body text is
|
||||||
|
unsafe. Match the personal building icon's computed fill to each legend swatch's
|
||||||
|
computed background color; unknown or ambiguous colors fail closed.
|
||||||
|
|
||||||
|
## Loading and recovery
|
||||||
|
|
||||||
|
Login, navigation and monthly selections finish asynchronously. Wait for the
|
||||||
|
expected DOM and the Vaadin Flow clients' `isActive()` queues to become idle.
|
||||||
|
Do not use `networkidle`: the app maintains background communication. Menu options
|
||||||
|
remain in the DOM after closing; locate visible top-level menu buttons separately.
|
||||||
|
|
||||||
|
`smarttime_browser.py` launches one isolated worker per read. A whole-job watchdog
|
||||||
|
also covers cases in which a Playwright DOM operation or browser shutdown hangs.
|
||||||
|
On Windows it terminates only that job's process tree (`taskkill /PID <job> /T /F`)
|
||||||
|
before retrying with a new browser. There is no browser profile lock or poisoned
|
||||||
|
thread queue to reuse after a failure. The old profile is left untouched on disk.
|
||||||
|
The subprocess boundary also resolves Streamlit's Windows SelectorEventLoop issue.
|
||||||
|
|
||||||
|
UI waits are bounded (normally 40 seconds); the hard job budgets are 75 seconds for
|
||||||
|
status and 180 seconds for export, with at most three read attempts. Exhaustion is
|
||||||
|
an unknown/error result, never proof that a booking failed.
|
||||||
|
|
||||||
|
## Monthly extraction
|
||||||
|
|
||||||
|
1. Click `vaadin-side-nav-item[path="MonthlyOV"]` and wait for `current`.
|
||||||
|
2. Read the visible month/year `vaadin-menu-bar-button` labels. Select the year and
|
||||||
|
month by their exact menu-item names; wait for the new labels and idle server queue.
|
||||||
|
3. Read `vaadin-grid.Customers-grid`. Its shadow root contains `#header`, `#items`
|
||||||
|
and the scrolling `#table`. Each cell's `<slot>` resolves to light-DOM content
|
||||||
|
via `assignedElements()`. This preserves empty columns and multiple bookings.
|
||||||
|
4. Use the grid's `scrollToIndex()` to render successive portions of the month.
|
||||||
|
The grid's `size` must equal the calendar's number of days. Collect by day,
|
||||||
|
compare overlapping rows, and require every date before writing the export.
|
||||||
|
|
||||||
|
Observed columns, in order: `Datum`, an unlabeled notes/icon column,
|
||||||
|
`Kommen / Gehen`, `Soll`, `Ist`, `TSaldo`, `Saldo`, `Pause`, `Abwesenheit`.
|
||||||
|
The unlabeled column is retained in `raw_cells`. Booking strings are kept in
|
||||||
|
display order; absence credits are not turned into fictitious bookings.
|
||||||
|
|
||||||
|
## CLI and diagnostics
|
||||||
|
|
||||||
|
`scripts/smarttime_cli.py` uses exactly the same reader/recovery code as the app.
|
||||||
|
`--month YYYY-MM` exports JSON; `--artifacts DIRECTORY` saves a screenshot and
|
||||||
|
sanitized HTML for the dashboard and multiple month scroll positions. The HTML
|
||||||
|
includes declarative shadow-root snapshots; ordinary `page.content()` omits them.
|
||||||
|
Input value attributes are stripped, but exported time data remains personal.
|
||||||
|
|
||||||
|
`scripts/smarttime_probe.py` is the separate interactive exploration tool. It uses
|
||||||
|
a fresh headless browser, accepts JSON commands on stdin, and saves local snapshots
|
||||||
|
under `.local/smarttime/<timestamp>`. Example commands:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"snapshot"}
|
||||||
|
{"action":"login","user":"input[name=username]","password":"input[name=password]"}
|
||||||
|
{"action":"selector","selector":"a[href=MonthlyOV]"}
|
||||||
|
{"action":"snapshot"}
|
||||||
|
{"action":"quit"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The probe loads credentials from settings without printing them. Its generic
|
||||||
|
selectors are intended for developer inspection; use only navigation/read controls.
|
||||||
|
The supported CLI exposes only read operations.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Live dashboard reads, the app's verification API, the Streamlit **Jetzt prüfen**
|
||||||
|
button, and full August/September 2026 and September 2025 exports were exercised.
|
||||||
|
The Streamlit check used the live backend with the auto-stamp scheduler mocked out.
|
||||||
|
Offline tests cover the real DOM-reading JavaScript,
|
||||||
|
status colors, delayed rendering, unknown/invalid values, slot extraction, full
|
||||||
|
calendar validation, and retry guards. A separate test launches real Chromium
|
||||||
|
with an intercepted navigation that never responds, then verifies watchdog cleanup.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Read SmartTime status or export a complete month, without starting Streamlit."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "stempelbot"))
|
||||||
|
|
||||||
|
from smarttime_browser import run_browser_job
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--month", type=lambda s: datetime.strptime(s, "%Y-%m"), help="Export YYYY-MM; omit for current dashboard status")
|
||||||
|
parser.add_argument("--output", type=Path, help="Write JSON to a new file (otherwise stdout)")
|
||||||
|
parser.add_argument("--artifacts", type=Path, help="Save local screenshots and sanitized HTML snapshots")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.output and args.output.exists():
|
||||||
|
parser.error("Output already exists; choose a new file")
|
||||||
|
result = run_browser_job(
|
||||||
|
"month" if args.month else "status",
|
||||||
|
year=args.month.year if args.month else None,
|
||||||
|
month=args.month.month if args.month else None,
|
||||||
|
artifacts=args.artifacts,
|
||||||
|
)
|
||||||
|
payload = json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
if args.output:
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with args.output.open("x", encoding="utf-8") as stream:
|
||||||
|
stream.write(payload + "\n")
|
||||||
|
print(f"Saved {args.output}")
|
||||||
|
else:
|
||||||
|
print(payload)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Interactive read-only SmartTime UI probe (separate from the app).
|
||||||
|
|
||||||
|
Reads JSON commands from stdin: snapshot, login, click (text), goto (url), quit.
|
||||||
|
Artifacts stay local; no credential or cookie values are printed.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "stempelbot"))
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
from settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
output = ROOT / ".local" / "smarttime" / datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
browser = pw.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(ignore_https_errors=True, locale="de-DE", viewport={"width": 1440, "height": 1100})
|
||||||
|
page = context.new_page()
|
||||||
|
page.set_default_timeout(8000)
|
||||||
|
page.goto(settings.SMART_TIME_URL, wait_until="domcontentloaded", timeout=20000)
|
||||||
|
print(f"ARTIFACTS {output}", flush=True)
|
||||||
|
count = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
command = json.loads(input("COMMAND> "))
|
||||||
|
action = command["action"]
|
||||||
|
if action == "quit":
|
||||||
|
break
|
||||||
|
if action == "evaluate":
|
||||||
|
print(json.dumps(page.evaluate(command["js"]), ensure_ascii=True), flush=True)
|
||||||
|
continue
|
||||||
|
if action == "run":
|
||||||
|
import runpy
|
||||||
|
runpy.run_path(command["file"])["run"](page, output)
|
||||||
|
continue
|
||||||
|
if action == "login":
|
||||||
|
page.locator(command["user"]).fill(settings.USERNAME)
|
||||||
|
page.locator(command["password"]).fill(settings.SMART_TIME_PASSWORD)
|
||||||
|
page.locator(command["password"]).press("Enter")
|
||||||
|
elif action == "click":
|
||||||
|
page.get_by_text(command["text"], exact=True).click()
|
||||||
|
elif action == "selector":
|
||||||
|
page.locator(command["selector"]).click()
|
||||||
|
elif action == "fill":
|
||||||
|
page.locator(command["selector"]).fill(command["value"])
|
||||||
|
page.locator(command["selector"]).press("Enter")
|
||||||
|
elif action == "goto":
|
||||||
|
page.goto(command["url"], wait_until="domcontentloaded", timeout=20000)
|
||||||
|
elif action == "inspect":
|
||||||
|
print(page.locator(command["selector"]).evaluate_all("els => els.map(e => e.outerHTML)"), flush=True)
|
||||||
|
count += 1
|
||||||
|
name = f"{count:02d}-{action}"
|
||||||
|
page.screenshot(path=str(output / f"{name}.png"), full_page=True, timeout=10000)
|
||||||
|
# Clone DOM and remove input values before persisting snapshots.
|
||||||
|
html = page.evaluate("""() => { const root = document.documentElement.cloneNode(true);
|
||||||
|
root.querySelectorAll('input').forEach(e => e.removeAttribute('value'));
|
||||||
|
return '<!DOCTYPE html>' + root.outerHTML; }""")
|
||||||
|
(output / f"{name}.html").write_text(html, encoding="utf-8")
|
||||||
|
print("URL", page.url, flush=True)
|
||||||
|
print(page.locator("body").inner_text(timeout=5000), flush=True)
|
||||||
|
if action == "inspect" or "login" in page.url:
|
||||||
|
print("CONTROLS", page.locator("input,button,a,select,[role=tab]").evaluate_all("els => els.map(e => ({tag:e.tagName, name:e.getAttribute('name'), type:e.getAttribute('type'), id:e.id, text:e.innerText, title:e.title, href:e.getAttribute('href')}))"), flush=True)
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
message = str(exc)
|
||||||
|
for secret in (settings.SMART_TIME_PASSWORD, settings.PASSWORD):
|
||||||
|
if secret:
|
||||||
|
message = message.replace(secret, "***")
|
||||||
|
print(type(exc).__name__, message, flush=True)
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
"""Offline fixture: stall a Chromium navigation forever until the parent kills it."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
json.load(sys.stdin)
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
browser = pw.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
# Intercept locally: there is no request to the network or to SmartTime.
|
||||||
|
page.route("**/*", lambda route: None)
|
||||||
|
page.goto("https://smarttime-hang.invalid/", timeout=0)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import calendar
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "stempelbot"))
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import smarttime_browser as browser_jobs
|
||||||
|
import smarttime_ui as ui
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal, anonymized markup matching the observed SmartTime 9 dashboard.
|
||||||
|
DASHBOARD = """
|
||||||
|
<div class="shadow-xs"><label>Test User</label>
|
||||||
|
<vaadin-icon icon="fas:phone" style="fill:green"></vaadin-icon>
|
||||||
|
<vaadin-icon icon="far:building" style="fill:COLOR"></vaadin-icon>
|
||||||
|
<vaadin-vertical-layout><label>Letzte Buchung</label><label>09:29</label></vaadin-vertical-layout>
|
||||||
|
</div>
|
||||||
|
<vaadin-vertical-layout><label>Anwesend</label><div style="background-color:green"></div></vaadin-vertical-layout>
|
||||||
|
<vaadin-vertical-layout><label>Außer Haus</label><div style="background-color:rgb(31, 161, 220)"></div></vaadin-vertical-layout>
|
||||||
|
<vaadin-vertical-layout><label>Abwesend</label><div style="background-color:red"></div></vaadin-vertical-layout>
|
||||||
|
<vaadin-vertical-layout><label>Entschuldigt</label><div style="background-color:orange"></div></vaadin-vertical-layout>
|
||||||
|
"""
|
||||||
|
HEADERS = ["Datum", "", "Kommen / Gehen", "Soll", "Ist", "TSaldo", "Saldo", "Pause", "Abwesenheit"]
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardDOMTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.pw = sync_playwright().start()
|
||||||
|
cls.browser = cls.pw.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.browser.close()
|
||||||
|
cls.pw.stop()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.page = self.browser.new_page()
|
||||||
|
self.addCleanup(self.page.close)
|
||||||
|
|
||||||
|
def test_status_comes_from_personal_card_not_legend_or_phone(self):
|
||||||
|
for color, status in (("green", "Anwesend"), ("red", "Abwesend"),
|
||||||
|
("rgb(31, 161, 220)", "Außer Haus"), ("orange", "Entschuldigt")):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
self.page.set_content(DASHBOARD.replace("COLOR", color))
|
||||||
|
result = ui.read_dashboard(self.page)
|
||||||
|
self.assertEqual(result["last_stamp"], "09:29")
|
||||||
|
self.assertEqual(result["status_text"], status)
|
||||||
|
|
||||||
|
def test_unknown_status_is_not_assumed_present(self):
|
||||||
|
self.page.set_content(DASHBOARD.replace("COLOR", "purple"))
|
||||||
|
with self.assertRaisesRegex(ui.SmartTimeReadError, "complete personal dashboard"):
|
||||||
|
ui.read_dashboard(self.page, timeout=0)
|
||||||
|
|
||||||
|
def test_waits_for_delayed_dashboard_values(self):
|
||||||
|
self.page.set_content(DASHBOARD.replace("COLOR", "green").replace("09:29", ""))
|
||||||
|
self.page.evaluate("""() => setTimeout(() => {
|
||||||
|
document.querySelector('.shadow-xs vaadin-vertical-layout label:last-child').textContent='09:29';
|
||||||
|
}, 100)""")
|
||||||
|
self.assertEqual(ui.read_dashboard(self.page)["last_stamp"], "09:29")
|
||||||
|
|
||||||
|
def test_invalid_time_is_not_accepted(self):
|
||||||
|
self.page.set_content(DASHBOARD.replace("COLOR", "green").replace("09:29", "29:90"))
|
||||||
|
with self.assertRaises(ui.SmartTimeReadError):
|
||||||
|
ui.read_dashboard(self.page, timeout=0)
|
||||||
|
|
||||||
|
def test_empty_grid_can_finish_rendering(self):
|
||||||
|
self.page.set_content('<vaadin-grid class="Customers-grid"></vaadin-grid>')
|
||||||
|
self.page.evaluate("() => document.querySelector('vaadin-grid').attachShadow({mode:'open'})")
|
||||||
|
self.assertIsNone(self.page.evaluate(ui.GRID_JS))
|
||||||
|
|
||||||
|
def test_virtual_grid_reads_slots_and_preserves_empty_columns(self):
|
||||||
|
self.page.set_content('<vaadin-grid class="Customers-grid"></vaadin-grid>')
|
||||||
|
values = [HEADERS, ["25", "", "09:29", "08:00", "00:00", "-08:00", "22:17", "00:00", ""]]
|
||||||
|
self.page.evaluate("""values => {
|
||||||
|
const g = document.querySelector('vaadin-grid'); g.size = 30;
|
||||||
|
const root = g.attachShadow({mode:'open'});
|
||||||
|
const table = document.createElement('div'); table.id='table'; root.append(table);
|
||||||
|
values.forEach((cells, i) => {
|
||||||
|
const section = document.createElement('div'); section.id = i ? 'items' : 'header';
|
||||||
|
const row = document.createElement('tr'); section.append(row); table.append(section);
|
||||||
|
cells.forEach((text, j) => {
|
||||||
|
const slot = document.createElement('slot'); slot.name=`cell-${i}-${j}`; row.append(slot);
|
||||||
|
const content = document.createElement('span'); content.slot=slot.name; content.innerText=text; g.append(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}""", values)
|
||||||
|
result = self.page.evaluate(ui.GRID_JS)
|
||||||
|
self.assertEqual(result["headers"], HEADERS)
|
||||||
|
self.assertEqual(result["rows"], [values[1]])
|
||||||
|
|
||||||
|
|
||||||
|
class MonthExportTests(unittest.TestCase):
|
||||||
|
def rows(self, year=2026, month=9):
|
||||||
|
return [[f"{d:02d}", "", "", "08:00", "00:00", "-08:00", "", "00:00", ""]
|
||||||
|
for d in range(1, calendar.monthrange(year, month)[1] + 1)]
|
||||||
|
|
||||||
|
def test_preserves_all_stamps_totals_absence_and_open_pair(self):
|
||||||
|
rows = self.rows()
|
||||||
|
rows[0][2] = "09:00\n12:00\n12:30\n17:45"
|
||||||
|
rows[0][4] = "08:15"
|
||||||
|
rows[1][8] = "Urlaub"
|
||||||
|
rows[24][2] = "09:29"
|
||||||
|
result = ui.normalize_month(2026, 9, HEADERS, list(reversed(rows)))
|
||||||
|
self.assertEqual(len(result["days"]), 30)
|
||||||
|
self.assertEqual(result["days"][0]["stamps"], ["09:00", "12:00", "12:30", "17:45"])
|
||||||
|
self.assertEqual(result["days"][0]["actual"], "08:15")
|
||||||
|
self.assertEqual(result["days"][1]["absence"], "Urlaub")
|
||||||
|
self.assertTrue(result["days"][24]["incomplete_pair"])
|
||||||
|
self.assertEqual(result["days"][24]["raw_cells"], rows[24])
|
||||||
|
|
||||||
|
def test_missing_duplicate_or_changed_columns_fail_closed(self):
|
||||||
|
rows = self.rows()
|
||||||
|
for headers, values in ((HEADERS, rows[:-1]), (HEADERS, rows[:-1] + [rows[0]]),
|
||||||
|
(HEADERS[::-1], rows)):
|
||||||
|
with self.subTest(headers=headers, count=len(values)):
|
||||||
|
with self.assertRaises(ui.SmartTimeReadError):
|
||||||
|
ui.normalize_month(2026, 9, headers, values)
|
||||||
|
|
||||||
|
def test_leap_month_has_all_29_dates(self):
|
||||||
|
self.assertEqual(len(ui.normalize_month(2024, 2, HEADERS, self.rows(2024, 2))["days"]), 29)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserRecoveryTests(unittest.TestCase):
|
||||||
|
def process(self, response=None, *, error=None):
|
||||||
|
process = Mock()
|
||||||
|
process.returncode = 0
|
||||||
|
process.poll.return_value = 0
|
||||||
|
if error:
|
||||||
|
process.communicate.side_effect = error
|
||||||
|
else:
|
||||||
|
process.communicate.return_value = (json.dumps(response), "")
|
||||||
|
return process
|
||||||
|
|
||||||
|
def test_hang_starts_fresh_browser_and_recovers(self):
|
||||||
|
hung = self.process(error=subprocess.TimeoutExpired("browser", 75))
|
||||||
|
good = self.process({"result": {"last_stamp": "09:29"}})
|
||||||
|
with (patch.object(browser_jobs.subprocess, "Popen", side_effect=[hung, good]) as spawn,
|
||||||
|
patch.object(browser_jobs, "stop_process_tree") as cleanup,
|
||||||
|
patch.object(browser_jobs.time, "sleep")):
|
||||||
|
self.assertEqual(browser_jobs.run_browser_job(), {"last_stamp": "09:29"})
|
||||||
|
self.assertEqual(spawn.call_count, 2)
|
||||||
|
self.assertEqual(cleanup.call_count, 2)
|
||||||
|
hung.stdin.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_permanent_hang_is_bounded_and_next_job_can_recover(self):
|
||||||
|
processes = [self.process(error=subprocess.TimeoutExpired("browser", 75)) for _ in range(3)]
|
||||||
|
with (patch.object(browser_jobs.subprocess, "Popen", side_effect=processes) as spawn,
|
||||||
|
patch.object(browser_jobs, "stop_process_tree"), patch.object(browser_jobs.time, "sleep")):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "after 3 browser reads"):
|
||||||
|
browser_jobs.run_browser_job()
|
||||||
|
self.assertEqual(spawn.call_count, 3)
|
||||||
|
|
||||||
|
def test_invalid_credentials_are_not_retried(self):
|
||||||
|
rejected = self.process({"error": "SmartTime rejected the login", "login_error": True})
|
||||||
|
rejected.returncode = 1
|
||||||
|
with (patch.object(browser_jobs.subprocess, "Popen", return_value=rejected) as spawn,
|
||||||
|
patch.object(browser_jobs, "stop_process_tree")):
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
browser_jobs.run_browser_job()
|
||||||
|
spawn.assert_called_once()
|
||||||
|
|
||||||
|
def test_real_hung_playwright_process_is_terminated(self):
|
||||||
|
fixture = Path(__file__).parent / "fixtures" / "hung_smarttime_browser.py"
|
||||||
|
processes = []
|
||||||
|
original_spawn = subprocess.Popen
|
||||||
|
|
||||||
|
def spawn(*args, **kwargs):
|
||||||
|
process = original_spawn(*args, **kwargs)
|
||||||
|
processes.append(process)
|
||||||
|
return process
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
with (patch.object(browser_jobs, "__file__", str(fixture)),
|
||||||
|
patch.object(browser_jobs, "STATUS_TIMEOUT_SEC", 3),
|
||||||
|
patch.object(browser_jobs, "BROWSER_ATTEMPTS", 1),
|
||||||
|
patch.object(browser_jobs.subprocess, "Popen", side_effect=spawn)):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "did not respond within 3s"):
|
||||||
|
browser_jobs.run_browser_job()
|
||||||
|
self.assertIsNotNone(processes[0].poll())
|
||||||
|
self.assertLess(time.monotonic() - started, 15)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user