mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
- from_report applies its change and reads the id and rev together, so a removal made while that change is sending is newer than the report; the report's redaction window now starts before the address is saved. - A report with a different address replaces the saved one with follow-up questions only: update notices aren't carried over to an address nobody agreed them for, and the form says so before sending. - Settings refreshes after every report send, whatever the box shows by then. - privacy.md: a report with follow-up ticked also saves and sends the address. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
413 lines
22 KiB
Python
413 lines
22 KiB
Python
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
|
|
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
|
|
|
|
Run: python3 -m unittest discover -s tests
|
|
"""
|
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT / "ui"))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import frame_compat_db as db # noqa: E402
|
|
import frame_contact as fc # noqa: E402
|
|
import frame_report as fr # noqa: E402
|
|
import frame_telemetry as tm # noqa: E402
|
|
from test_telemetry import Base, ReportProblem # noqa: E402
|
|
|
|
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
|
|
|
|
|
|
class Contact(Base):
|
|
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
|
|
serve = ReportProblem.serve
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.addCleanup(fc._removed.clear)
|
|
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
|
|
p = mock.patch.object(fc, name, value)
|
|
p.start()
|
|
self.addCleanup(p.stop)
|
|
self.got = self.serve()
|
|
|
|
def events(self):
|
|
return [body["batch"][0] for _, body in self.got]
|
|
|
|
def offline(self):
|
|
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
|
|
|
|
# ---- storage and consent flags
|
|
|
|
def test_nothing_is_kept_or_sent_until_chosen(self):
|
|
s = fc.state()
|
|
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
|
|
self.assertFalse(fc.FILE.exists())
|
|
self.assertEqual(self.got, [])
|
|
|
|
def test_an_address_needs_a_choice_and_a_real_address(self):
|
|
with self.assertRaisesRegex(ValueError, "tick"):
|
|
fc.save({"email": "me@example.com"})
|
|
with self.assertRaisesRegex(ValueError, "email address"):
|
|
fc.save({"email": "not an address", "updates": True})
|
|
self.assertEqual(fc.load()["email"], "")
|
|
self.assertEqual(self.got, [])
|
|
|
|
def test_only_a_real_true_counts_as_consent(self):
|
|
for wrong in ("false", "true", 1, 0, [], {}):
|
|
with self.assertRaisesRegex(ValueError, "true or false"):
|
|
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
|
|
with self.assertRaisesRegex(ValueError, "true or false"):
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
|
|
self.assertEqual((fc.load()["email"], self.got), ("", []))
|
|
fc.save({"email": "me@example.com", "updates": True}) # left out is no
|
|
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
|
|
|
|
def test_each_choice_is_sent_privately_on_its_own(self):
|
|
fc.save({"email": " me@example.com ", "updates": True})
|
|
fc.save({"email": "me@example.com", "updates": False, "followup": True})
|
|
first, second = self.events()
|
|
self.assertEqual(first["event"], "contact_consent")
|
|
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
|
|
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
|
|
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
|
|
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
|
|
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
|
|
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
|
|
(False, True))
|
|
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
|
|
|
|
def test_sent_whatever_the_analytics_settings(self):
|
|
tm.update_settings({"usage": False})
|
|
fc.save({"email": "me@example.com", "followup": True})
|
|
self.assertEqual(len(self.got), 1)
|
|
|
|
def test_saving_the_same_choice_again_sends_nothing(self):
|
|
fc.save({"email": "me@example.com", "updates": True})
|
|
fc.save({"email": "me@example.com", "updates": True})
|
|
self.assertEqual(len(self.got), 1)
|
|
|
|
# ---- withdrawal
|
|
|
|
def test_removing_the_address_sends_a_withdrawal_without_it(self):
|
|
fc.save({"email": "me@example.com", "updates": True, "followup": True})
|
|
s = fc.save({"email": "", "updates": True, "followup": True})
|
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
|
|
withdrawal = self.events()[-1]["properties"]
|
|
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
|
|
("withdraw", "", False, False))
|
|
self.assertNotIn("me@example.com", fc.FILE.read_text())
|
|
|
|
def test_an_address_still_waiting_is_withdrawn_too(self):
|
|
with self.offline():
|
|
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
|
|
with mock.patch.object(tm, "post") as post:
|
|
fc.save({"email": ""})
|
|
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
|
|
self.assertFalse(fc.state()["waiting"])
|
|
|
|
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
|
|
fc.save({"email": "me@example.com", "updates": True})
|
|
with self.offline():
|
|
s = fc.save({"email": ""})
|
|
self.assertTrue(s["waiting"])
|
|
self.assertFalse(fc._send_pending())
|
|
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
|
|
self.assertTrue(fc._send_pending())
|
|
self.assertFalse(fc.state()["waiting"])
|
|
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
|
|
|
|
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
|
|
fc.save({"email": "me@example.com", "followup": True})
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
self.assertIn("me@example.com", tm.SENT.read_text())
|
|
fc.save({"email": ""})
|
|
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
|
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
|
|
if e["event"] == "contact_consent"], ["set", "withdraw"])
|
|
|
|
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
|
|
fc.save({"email": "me@example.com", "updates": True})
|
|
fc.save({"email": "new@example.com", "updates": True})
|
|
fc.save({"email": ""})
|
|
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
|
|
|
|
def test_a_withdrawal_during_a_send_goes_after_it(self):
|
|
started, release, order = threading.Event(), threading.Event(), []
|
|
real = tm.post
|
|
|
|
def slow(batch, timeout=20):
|
|
order.append(batch[0]["properties"]["action"])
|
|
if len(order) == 1:
|
|
started.set()
|
|
release.wait(5)
|
|
real(batch, timeout)
|
|
|
|
with mock.patch.object(tm, "post", side_effect=slow):
|
|
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
|
|
t.start()
|
|
self.assertTrue(started.wait(5))
|
|
w = threading.Thread(target=fc.save, args=({"email": ""},))
|
|
w.start()
|
|
for _ in range(500): # the withdrawal is saved while the first send is still out
|
|
if fc.load()["rev"] == 2:
|
|
break
|
|
time.sleep(0.01)
|
|
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
|
|
release.set()
|
|
t.join(5)
|
|
w.join(5)
|
|
self.assertEqual(order, ["set", "withdraw"])
|
|
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
|
|
self.assertFalse(fc.state()["waiting"])
|
|
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
|
|
|
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
|
|
fc.save({"email": "me@example.com", "followup": True})
|
|
real = tm.post
|
|
|
|
def remove_meanwhile(batch, timeout=20):
|
|
real(batch, timeout)
|
|
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
|
|
|
|
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
|
|
|
|
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
|
|
fc._removed["me@example.com"] = 1790000000.3
|
|
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
|
|
before, after = event(), event() # the same whole second as the removal
|
|
fc.redact_removed(before, 1790000000.1)
|
|
fc.redact_removed(after, 1790000000.6)
|
|
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
|
|
("<removed>", "me@example.com"))
|
|
|
|
def test_saving_during_a_slow_send_returns_at_once(self):
|
|
busy = fc._send_lock
|
|
busy.acquire()
|
|
try:
|
|
s = fc.save({"email": "me@example.com", "updates": True})
|
|
finally:
|
|
busy.release()
|
|
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
|
|
self.assertEqual(self.got, [])
|
|
self.assertTrue(fc._send_pending())
|
|
self.assertEqual(len(self.got), 1)
|
|
|
|
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
|
|
real = fc._send_lock
|
|
|
|
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
|
|
saved = False
|
|
|
|
def acquire(self, blocking=True):
|
|
return real.acquire(blocking)
|
|
|
|
def release(self):
|
|
if not Lock.saved:
|
|
Lock.saved = True
|
|
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
|
|
s.start()
|
|
s.join(5)
|
|
assert not s.is_alive() # the change is saved while the sender still holds the lock
|
|
real.release()
|
|
|
|
with mock.patch.object(fc, "_send_lock", Lock()):
|
|
self.assertTrue(fc._send_pending())
|
|
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
|
|
self.assertFalse(fc.state()["waiting"])
|
|
|
|
# ---- the one-time prompt
|
|
|
|
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
|
|
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
|
|
tm.frame_seen("20260901.1", "3.8")
|
|
self.assertTrue(fc.state()["showPrompt"])
|
|
fc.prompt({"prompt": "dismissed"})
|
|
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
|
|
self.assertEqual(fc.load()["prompt"], "dismissed")
|
|
self.assertFalse(fc.state()["showPrompt"])
|
|
self.assertEqual(self.got, []) # No thanks sends nothing
|
|
with self.assertRaises(ValueError):
|
|
fc.prompt({"prompt": "reset"})
|
|
|
|
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
|
|
tm.frame_seen("20260901.1", "3.8")
|
|
fc.prompt({"prompt": "shown"})
|
|
self.assertFalse(fc.state()["showPrompt"])
|
|
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
|
|
self.assertEqual(fc.load()["prompt"], "answered")
|
|
|
|
# ---- reports and the maintainer's list
|
|
|
|
def reports(self):
|
|
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
|
|
|
|
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
|
fr.send({**REPORT, "contact": "me@example.com"})
|
|
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
without, with_ = self.reports()
|
|
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
|
|
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
|
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
|
|
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
|
|
with self.assertRaisesRegex(ValueError, "email address"):
|
|
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
|
|
|
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
s = fc.state()
|
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
|
|
consent = [e for e in self.events() if e["event"] == "contact_consent"]
|
|
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
|
|
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
|
|
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
|
|
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
|
|
self.assertEqual(self.reports()[1]["contact_rev"], 1)
|
|
fc.save({"email": ""}) # Remove my email
|
|
last = self.events()[-1]
|
|
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
|
|
("withdraw", "", 2))
|
|
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
|
self.assertEqual(logged, ["<removed>", "<removed>"])
|
|
|
|
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
|
|
"""Update notices were agreed for the old address, not the new one (the form says so)."""
|
|
fc.save({"email": "old@example.com", "updates": True})
|
|
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
|
|
s = fc.state()
|
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
|
|
self.assertEqual(self.reports()[0]["contact_rev"], 2)
|
|
fc.save({"email": "new@example.com", "updates": True, "followup": False})
|
|
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
|
|
s = fc.state()
|
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
|
|
|
|
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
|
|
"""Removed while the report's own consent is on its way: the report keeps that consent's
|
|
rev (so the removal is newer) and is logged without the address."""
|
|
post, removed = tm.post, []
|
|
|
|
def slow_post(events, **kw):
|
|
post(events, **kw)
|
|
if not removed and events[0]["event"] == "contact_consent":
|
|
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
|
|
with mock.patch.object(tm, "post", side_effect=slow_post):
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
report = self.reports()[0]
|
|
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
|
|
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
|
for e in self.events() if e["event"] == "contact_consent"]
|
|
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
|
fr.mark_withdrawn([row], consents)
|
|
self.assertEqual(row[10], "withdrawn")
|
|
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
|
self.assertEqual(logged, ["<removed>"])
|
|
|
|
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
|
|
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
|
|
"0.4.0", "Windows", "", "", followup, cid, rev]
|
|
|
|
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
|
|
reports = [self.report_row(), # removed later
|
|
self.report_row(cid="other"), # another copy, still agrees
|
|
self.report_row(rev=3), # sent after the removal
|
|
self.report_row(cid="moved"), # address changed later
|
|
self.report_row(cid="news-only"), # follow-up unticked later
|
|
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
|
|
self.report_row(cid="", followup=True), # no contact id: left alone
|
|
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
|
|
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
|
|
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
|
|
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
|
|
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
|
|
fr.mark_withdrawn(reports, consents)
|
|
self.assertEqual([r[10] for r in reports],
|
|
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
|
|
|
|
def test_the_change_number_decides_not_the_clock(self):
|
|
"""The clock went back between the report and the removal: the removal still counts."""
|
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
|
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
|
|
fc.save({"email": ""})
|
|
report = self.reports()[0]
|
|
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
|
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
|
for e in self.events() if e["event"] == "contact_consent"]
|
|
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
|
|
fr.mark_withdrawn([row], consents)
|
|
self.assertEqual(row[10], "withdrawn")
|
|
|
|
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
|
|
reports = [self.report_row(), ["short"]]
|
|
consents = [["copy", "", False, 2]]
|
|
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
|
|
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
|
|
mock.patch("builtins.print") as out:
|
|
fr.main()
|
|
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
|
|
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
|
|
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
|
self.assertIn("follow-up permission since withdrawn", printed)
|
|
self.assertNotIn("me@example.com", printed)
|
|
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
|
|
fr.inbox()
|
|
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
|
|
|
|
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
|
|
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
|
|
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
|
|
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
|
|
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
|
|
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
|
|
found = fr.contacts()
|
|
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
|
|
q.call_args.args[0])
|
|
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
|
|
"followup": [("both@example.com", "2026-09-01")]})
|
|
with mock.patch.object(fr, "contacts", return_value=found), \
|
|
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
|
|
mock.patch("builtins.print") as out:
|
|
fr.main()
|
|
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
|
self.assertIn("both@example.com", printed)
|
|
self.assertNotIn("news@example.com", printed)
|
|
|
|
def test_the_page_can_reach_it(self):
|
|
import server
|
|
self.assertIs(server.POST["/api/contact"], fc.save)
|
|
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
|
|
|
|
def test_saving_is_not_headset_work(self):
|
|
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
|
|
import io
|
|
import server
|
|
seen = []
|
|
for path in ("/api/contact", "/api/contact/prompt"):
|
|
h = server.Handler.__new__(server.Handler)
|
|
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
|
|
h.path, h.rfile = path, io.BytesIO(body)
|
|
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
|
|
h.local_request = lambda: True
|
|
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
|
|
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
|
|
h.do_POST()
|
|
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
|
|
|
|
|
|
# Run these once, in test_telemetry, not again through the import above.
|
|
del Base, ReportProblem
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|