test_smoke.py raw

   1  """Smoke: the relay serves the app, the app boots, you can sign in, and the
   2  kind-1 forum is parked (no forum UI, empty home page, empty notifications)."""
   3  
   4  import json
   5  import os
   6  import urllib.error
   7  import urllib.request
   8  
   9  import pytest
  10  
  11  from conftest import (
  12      ARTIFACTS_DIR,
  13      RELAY_URL,
  14      TEST_NSEC,
  15      TEST_PK,
  16      VAULT_PASSWORD,
  17      visible,
  18      wait_wasm_ready,
  19  )
  20  
  21  BUNDLES = [
  22      "app.wasm",
  23      "relay-proxy.wasm",
  24      "store.wasm",
  25      "verify.wasm",
  26      "profile.wasm",
  27      "notif.wasm",
  28      "signer.wasm",
  29  ]
  30  
  31  READY_STATUS = ("verify", "store", "rproxy", "signer", "app", "domain", "profile", "notif")
  32  
  33  
  34  class TestStatic:
  35      """The relay serves a deployable build out of web/static."""
  36  
  37      def test_index(self, relay):
  38          html = urllib.request.urlopen(f"{relay['url']}/").read().decode()
  39          assert "<!DOCTYPE html>" in html
  40          assert 'id="app-root"' in html
  41  
  42      def test_bundles(self, relay):
  43          for name in BUNDLES:
  44              body = urllib.request.urlopen(f"{relay['url']}/{name}").read()
  45              assert body[:4] == b"\x00asm", f"{name} is not a wasm module"
  46  
  47      def test_parked_bundles_not_served(self, relay):
  48          """feed.wasm and mls.wasm are park-state: not built, not served."""
  49          for name in ("feed.wasm", "mls.wasm"):
  50              try:
  51                  body = urllib.request.urlopen(f"{relay['url']}/{name}").read()
  52              except urllib.error.HTTPError:
  53                  continue
  54              assert body[:4] != b"\x00asm", f"{name} must not be served"
  55  
  56  
  57  @pytest.fixture(scope="session")
  58  def app(page):
  59      """Sign in through the real signer panel and land in the shell."""
  60      sign_in(page)
  61      return page
  62  
  63  
  64  def sign_in(pg):
  65      """Create a vault with a known nsec through the signer panel, then sign in.
  66  
  67      This is the user's path: the login button opens the signer panel, the panel
  68      creates the vault and imports the nsec, and the identity's own Login button
  69      switches to it and shows the shell. Nothing here reloads the page - a
  70      reload depends on the unlocked session being restored first and races it.
  71      """
  72      pg.get_by_role("button", name="login").click()
  73      pg.wait_for_selector("input[placeholder='nsec1...']", timeout=15000)
  74      pg.fill("input[placeholder='Vault password']", VAULT_PASSWORD)
  75      pg.fill("input[placeholder='nsec1...']", TEST_NSEC)
  76      pg.get_by_role("button", name="create vault with nsec").click()
  77  
  78      # The identity must be in the vault and the vault on disk before signing
  79      # in: the identity add and the vault write are separate async steps.
  80      pg.wait_for_function(
  81          "pk => window.__musiquay_get_public_key().then(k => k === pk)",
  82          arg=TEST_PK,
  83          timeout=20000,
  84      )
  85      pg.wait_for_function("() => !!localStorage.getItem('musiquay-vault')", timeout=10000)
  86  
  87      # The identity's own Login button: switch to it and enter the shell.
  88      pg.wait_for_selector("text=Identities", timeout=15000)
  89      pg.evaluate("""() => {
  90          const b = Array.from(document.querySelectorAll('button'))
  91              .find(e => (e.textContent || '').trim() === 'Login');
  92          if (b) b.click();
  93      }""")
  94      pg.wait_for_selector("#content-area", state="attached", timeout=30000)
  95      pg.wait_for_selector("#home-page", timeout=30000)
  96  
  97  
  98  class TestBoot:
  99      def test_login_page(self, page):
 100          """Before sign-in: the login page is branded and translated."""
 101          body = page.inner_text("body")
 102          assert "MUSIQUAY" in body
 103          assert "the digital home for the music scene" in body
 104          assert "subtitle" not in body, "i18n fell back to the key"
 105  
 106      def test_boot_status(self, page):
 107          """Every worker reports ok, and the feed worker reports parked."""
 108          # The workers report as they come up; the last line lands after the app
 109          # is ready, so wait for the record to settle before reading it.
 110          page.wait_for_function(
 111              """() => {
 112                  const d = JSON.parse(localStorage.getItem('__musiquay_boot_diag') || '{}').status || {};
 113                  return d.feed === 'parked' && ['verify','store','rproxy','signer','app','domain','profile','notif']
 114                      .every(k => d[k] === 'ok');
 115              }""",
 116              timeout=30000,
 117          )
 118          status = json.loads(page.evaluate("() => localStorage.getItem('__musiquay_boot_diag')"))["status"]
 119          assert status.get("feed") == "parked"
 120          for name in READY_STATUS:
 121              assert status.get(name) == "ok", f"{name} = {status.get(name)}"
 122          assert page.evaluate("() => document.body.getAttribute('data-wasm-ready')") == "1"
 123  
 124      def test_no_page_errors(self, page):
 125          assert page.boot_errors == []
 126  
 127  
 128  class TestShell:
 129      def test_signed_in(self, app):
 130          """Auto-login put the seeded identity in the shell."""
 131          assert visible(app, "#content-area")
 132          assert app.evaluate("() => localStorage.getItem('musiquay-pubkey')") == TEST_PK
 133          assert app.evaluate("() => document.querySelector('input[placeholder=\"nsec1...\"]') === null")
 134  
 135      def test_home_page_is_the_main_area(self, app):
 136          assert visible(app, "#home-page")
 137          for parked in ("#feed-page", "#notif-page", "#compose-page", "#search-page", "#thread-page"):
 138              assert not visible(app, parked), f"{parked} must not be shown at the root"
 139  
 140      def test_no_forum_ui(self, app):
 141          """No kind-1 forum surface: no forum text, no feed/compose nav buttons."""
 142          assert app.evaluate("() => document.body.innerText.toLowerCase().includes('forum')") is False
 143          labels = app.evaluate(
 144              "() => Array.from(document.querySelectorAll('button, a'))"
 145              ".map(e => (e.innerText || '').trim().toLowerCase())"
 146          )
 147          assert "feed" not in labels
 148          assert "compose" not in labels
 149          assert app.evaluate("() => document.querySelectorAll('svg').length") > 0, "shell did not render"
 150  
 151      def test_forum_routes_land_on_home(self, app):
 152          """Every forum route collapses onto the empty home page."""
 153          for route in ("/feed", "/feed/follows", "/t/abc123", "/compose", "/search"):
 154              app.goto(f"{RELAY_URL}{route}", wait_until="domcontentloaded")
 155              wait_wasm_ready(app)
 156              app.wait_for_selector("#home-page", timeout=15000)
 157              assert visible(app, "#home-page"), f"{route} did not land on home"
 158  
 159      def test_notifications_empty(self, app):
 160          app.goto(f"{RELAY_URL}/notifications", wait_until="domcontentloaded")
 161          wait_wasm_ready(app)
 162          app.wait_for_selector("#notif-page", timeout=15000)
 163          assert visible(app, "#notif-page")
 164          rows = app.evaluate(
 165              "() => document.querySelectorAll('#notif-page [data-notif-row], #notif-page .notif-row').length"
 166          )
 167          assert rows == 0, f"notifications are not empty ({rows} rows)"
 168  
 169      def test_screenshot(self, app):
 170          path = os.path.join(ARTIFACTS_DIR, "musiquay-signed-in.png")
 171          app.goto(RELAY_URL, wait_until="domcontentloaded")
 172          wait_wasm_ready(app)
 173          app.wait_for_selector("#home-page")
 174          app.screenshot(path=path, full_page=False)
 175          assert os.path.getsize(path) > 5000
 176