Spaces:
Sleeping
Sleeping
Commit Β·
cf9ef2d
1
Parent(s): 0aca675
feat(audit): Tier 4 functionality (API smoke + Playwright E2E) + selftest fixtures
Browse files- audit/e2e/insurancebot_e2e.js +151 -0
- audit/selftest_fixtures.py +156 -0
- audit/tier4_functional.py +147 -1
audit/e2e/insurancebot_e2e.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tier-4 E2E β deterministic UI-presence journeys for the Insurance Advisor
|
| 3 |
+
* frontend. Run via the playwright-skill runner:
|
| 4 |
+
* node /Users/rohitsar/.claude/skills/playwright-skill/run.js \
|
| 5 |
+
* audit/e2e/insurancebot_e2e.js
|
| 6 |
+
*
|
| 7 |
+
* The runner injects Playwright (resolved from the skill's node_modules) and
|
| 8 |
+
* passes this file through verbatim because it already has `require(` + an
|
| 9 |
+
* async IIFE. Every journey resolves to a boolean; everything is wrapped so a
|
| 10 |
+
* single `RJSON {...}` line is ALWAYS printed to stdout β even on a hard
|
| 11 |
+
* failure β so the Python check can parse it (no RJSON => WARN/infra, never a
|
| 12 |
+
* silent FAIL). These are presence/no-error checks only: NO multi-turn LLM
|
| 13 |
+
* chat (that is flaky/slow and not a deterministic UI signal).
|
| 14 |
+
*/
|
| 15 |
+
const { chromium } = require('playwright');
|
| 16 |
+
|
| 17 |
+
const TARGET = process.env.TARGET_URL || 'http://localhost:3000';
|
| 18 |
+
|
| 19 |
+
(async () => {
|
| 20 |
+
const R = {
|
| 21 |
+
loads: false,
|
| 22 |
+
marketplaceRenders: false,
|
| 23 |
+
logosOk: false,
|
| 24 |
+
composerWorks: false,
|
| 25 |
+
noConsoleError: false,
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
let browser;
|
| 29 |
+
// Console / page errors collected across the whole session.
|
| 30 |
+
const errors = [];
|
| 31 |
+
|
| 32 |
+
try {
|
| 33 |
+
browser = await chromium.launch({ headless: true });
|
| 34 |
+
const context = await browser.newContext({
|
| 35 |
+
viewport: { width: 1440, height: 900 },
|
| 36 |
+
});
|
| 37 |
+
const page = await context.newPage();
|
| 38 |
+
|
| 39 |
+
page.on('pageerror', (e) => errors.push('pageerror: ' + (e && e.message)));
|
| 40 |
+
page.on('console', (msg) => {
|
| 41 |
+
if (msg.type() === 'error') errors.push('console.error: ' + msg.text());
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
// ---- journey: loads -------------------------------------------------
|
| 45 |
+
try {
|
| 46 |
+
await page.goto(TARGET, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
| 47 |
+
await page.waitForSelector('h1', { state: 'visible', timeout: 30000 });
|
| 48 |
+
R.loads = (await page.locator('h1').count()) > 0;
|
| 49 |
+
} catch (e) {
|
| 50 |
+
errors.push('loads: ' + (e && e.message));
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// ---- journey: marketplaceRenders -----------------------------------
|
| 54 |
+
// Open the Policy-Library / marketplace panel, assert >=1 policy card is
|
| 55 |
+
// visible, and assert no horizontal overflow at 1440px.
|
| 56 |
+
try {
|
| 57 |
+
const trigger = page
|
| 58 |
+
.locator(
|
| 59 |
+
'button:has-text("Policy Library"), button:has-text("ΰ€ͺΰ₯ΰ€²ΰ€Ώΰ€Έΰ₯ ΰ€²ΰ€Ύΰ€ΰ€¬ΰ₯ΰ€°ΰ₯ΰ€°ΰ₯")'
|
| 60 |
+
)
|
| 61 |
+
.first();
|
| 62 |
+
await trigger.click({ timeout: 20000 });
|
| 63 |
+
await page.waitForSelector('.policy-card', {
|
| 64 |
+
state: 'visible',
|
| 65 |
+
timeout: 30000,
|
| 66 |
+
});
|
| 67 |
+
const cards = await page.locator('.policy-card:visible').count();
|
| 68 |
+
const overflow = await page.evaluate(
|
| 69 |
+
() =>
|
| 70 |
+
document.documentElement.scrollWidth >
|
| 71 |
+
window.innerWidth + 1
|
| 72 |
+
);
|
| 73 |
+
R.marketplaceRenders = cards >= 1 && !overflow;
|
| 74 |
+
} catch (e) {
|
| 75 |
+
errors.push('marketplaceRenders: ' + (e && e.message));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// ---- journey: logosOk ----------------------------------------------
|
| 79 |
+
// At least one insurer logo <img src*="insurer-logos"> with naturalWidth>0.
|
| 80 |
+
try {
|
| 81 |
+
await page
|
| 82 |
+
.waitForSelector('img[src*="insurer-logos"]', { timeout: 20000 })
|
| 83 |
+
.catch(() => {});
|
| 84 |
+
const logoLoaded = await page.evaluate(() => {
|
| 85 |
+
const imgs = Array.from(
|
| 86 |
+
document.querySelectorAll('img[src*="insurer-logos"]')
|
| 87 |
+
);
|
| 88 |
+
if (imgs.length === 0) return false;
|
| 89 |
+
return imgs.some((im) => im.naturalWidth && im.naturalWidth > 0);
|
| 90 |
+
});
|
| 91 |
+
R.logosOk = logoLoaded;
|
| 92 |
+
} catch (e) {
|
| 93 |
+
errors.push('logosOk: ' + (e && e.message));
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ---- journey: composerWorks ----------------------------------------
|
| 97 |
+
// The chat composer textarea is fillable and the Send button is clickable
|
| 98 |
+
// once non-empty. (We do NOT actually submit a turn β no LLM dependency.)
|
| 99 |
+
try {
|
| 100 |
+
// The marketplace panel is a single-active <aside> that REPLACES the
|
| 101 |
+
// chat view, so the composer textarea stays in the DOM but is hidden
|
| 102 |
+
// while the panel is open. Toggle the Policy-Library trigger again to
|
| 103 |
+
// close the panel (its onClick is togglePanel(...)), then wait for the
|
| 104 |
+
// composer to become visible again.
|
| 105 |
+
const ml = page
|
| 106 |
+
.locator(
|
| 107 |
+
'button:has-text("Policy Library"), button:has-text("ΰ€ͺΰ₯ΰ€²ΰ€Ώΰ€Έΰ₯ ΰ€²ΰ€Ύΰ€ΰ€¬ΰ₯ΰ€°ΰ₯ΰ€°ΰ₯")'
|
| 108 |
+
)
|
| 109 |
+
.first();
|
| 110 |
+
if (await ml.isVisible().catch(() => false)) {
|
| 111 |
+
await ml.click({ timeout: 10000 }).catch(() => {});
|
| 112 |
+
}
|
| 113 |
+
const ta = page.locator('textarea[aria-label="Message"]').first();
|
| 114 |
+
await ta.waitFor({ state: 'visible', timeout: 20000 });
|
| 115 |
+
await ta.fill('audit e2e presence check');
|
| 116 |
+
const val = await ta.inputValue();
|
| 117 |
+
const send = page
|
| 118 |
+
.locator('button:has-text("Send")')
|
| 119 |
+
.first();
|
| 120 |
+
// After typing, Send must no longer be disabled (it gates on input).
|
| 121 |
+
const sendEnabled = await send.isEnabled().catch(() => false);
|
| 122 |
+
R.composerWorks = val === 'audit e2e presence check' && sendEnabled;
|
| 123 |
+
} catch (e) {
|
| 124 |
+
errors.push('composerWorks: ' + (e && e.message));
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// ---- journey: noConsoleError ---------------------------------------
|
| 128 |
+
// No pageerror / console.error captured during the whole session.
|
| 129 |
+
R.noConsoleError = errors.every(
|
| 130 |
+
(m) =>
|
| 131 |
+
!m.startsWith('pageerror:') && !m.startsWith('console.error:')
|
| 132 |
+
);
|
| 133 |
+
} catch (fatal) {
|
| 134 |
+
// Hard failure (browser launch, etc.) β journeys stay false; still emit
|
| 135 |
+
// RJSON so the Python side classifies (FAIL/WARN), never a silent hang.
|
| 136 |
+
errors.push('fatal: ' + (fatal && fatal.message));
|
| 137 |
+
} finally {
|
| 138 |
+
try {
|
| 139 |
+
if (browser) await browser.close();
|
| 140 |
+
} catch (_) {
|
| 141 |
+
/* ignore */
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (errors.length) {
|
| 146 |
+
console.log('E2E_DIAG ' + JSON.stringify(errors.slice(0, 20)));
|
| 147 |
+
}
|
| 148 |
+
console.log('RJSON ' + JSON.stringify(R));
|
| 149 |
+
// Exit 0 regardless: the boolean payload (not the exit code) is the signal.
|
| 150 |
+
process.exit(0);
|
| 151 |
+
})();
|
audit/selftest_fixtures.py
CHANGED
|
@@ -311,6 +311,156 @@ def _f_t3_3():
|
|
| 311 |
helper.unlink()
|
| 312 |
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
FIXTURES.update({
|
| 315 |
"T1.1": _f_t1_1,
|
| 316 |
"T1.2": _f_t1_2,
|
|
@@ -326,4 +476,10 @@ FIXTURES.update({
|
|
| 326 |
"T3.1": _f_t3_1,
|
| 327 |
"T3.2": _f_t3_2,
|
| 328 |
"T3.3": _f_t3_3,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
})
|
|
|
|
| 311 |
helper.unlink()
|
| 312 |
|
| 313 |
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
# Tier 4 fixtures.
|
| 316 |
+
#
|
| 317 |
+
# T4 checks hit a LIVE local backend / frontend / playwright runner, so a
|
| 318 |
+
# file-on-disk fixture (the T1βT3 pattern) cannot force a deterministic FAIL.
|
| 319 |
+
# Instead each fixture monkeypatches the *module seams* of
|
| 320 |
+
# `audit.tier4_functional` (`_backend_up`, `_get`, `_post`, the module's
|
| 321 |
+
# `urllib.request.urlopen`, `sh`) so the check exercises its real logic against
|
| 322 |
+
# a controlled response and returns FAIL. Every patch is captured BEFORE the
|
| 323 |
+
# yield and restored UNCONDITIONALLY in `finally` β even if the check raises β
|
| 324 |
+
# so no module attr (and no file) is left mutated. No repo files are touched.
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@contextlib.contextmanager
|
| 329 |
+
def _f_t4_1():
|
| 330 |
+
"""Backend 'up' but /api/health reports a non-ok status -> T4.1 FAIL."""
|
| 331 |
+
import audit.tier4_functional as m
|
| 332 |
+
orig_up, orig_get = m._backend_up, m._get
|
| 333 |
+
m._backend_up = lambda: True
|
| 334 |
+
m._get = lambda p, timeout=20: (200, '{"status":"bad"}')
|
| 335 |
+
try:
|
| 336 |
+
yield
|
| 337 |
+
finally:
|
| 338 |
+
m._backend_up, m._get = orig_up, orig_get
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@contextlib.contextmanager
|
| 342 |
+
def _f_t4_2():
|
| 343 |
+
"""Coverage counts far outside the sane window -> T4.2 FAIL."""
|
| 344 |
+
import audit.tier4_functional as m
|
| 345 |
+
orig_up, orig_get = m._backend_up, m._get
|
| 346 |
+
m._backend_up = lambda: True
|
| 347 |
+
m._get = lambda p, timeout=20: (
|
| 348 |
+
200,
|
| 349 |
+
'{"total_policies":1,"total_insurers":1,"total_chunks":1}',
|
| 350 |
+
)
|
| 351 |
+
try:
|
| 352 |
+
yield
|
| 353 |
+
finally:
|
| 354 |
+
m._backend_up, m._get = orig_up, orig_get
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
@contextlib.contextmanager
|
| 358 |
+
def _f_t4_3():
|
| 359 |
+
"""Chat returns an empty reply_text -> T4.3 FAIL (empty reply)."""
|
| 360 |
+
import audit.tier4_functional as m
|
| 361 |
+
orig_up, orig_post = m._backend_up, m._post
|
| 362 |
+
m._backend_up = lambda: True
|
| 363 |
+
m._post = lambda p, payload, timeout=90: (
|
| 364 |
+
200,
|
| 365 |
+
'{"reply_text":"","brain_used":"x"}',
|
| 366 |
+
)
|
| 367 |
+
try:
|
| 368 |
+
yield
|
| 369 |
+
finally:
|
| 370 |
+
m._backend_up, m._post = orig_up, orig_post
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@contextlib.contextmanager
|
| 374 |
+
def _f_t4_4():
|
| 375 |
+
"""Make the junk-PDF upload POST 'succeed' (fake 200) -> T4.4 FAIL.
|
| 376 |
+
|
| 377 |
+
T4.4 calls `urllib.request.urlopen` directly (not via `_post`). A real junk
|
| 378 |
+
upload raises HTTPError(4xx) -> PASS. To force the FAIL branch we patch the
|
| 379 |
+
`urllib.request.urlopen` *as referenced inside tier4_functional* so the
|
| 380 |
+
POST returns a fake 200 response instead of raising β i.e. the app
|
| 381 |
+
'accepted junk', which T4.4 must report as FAIL.
|
| 382 |
+
"""
|
| 383 |
+
import audit.tier4_functional as m
|
| 384 |
+
orig_up = m._backend_up
|
| 385 |
+
orig_urlopen = m.urllib.request.urlopen
|
| 386 |
+
|
| 387 |
+
class _FakeResp:
|
| 388 |
+
status = 200
|
| 389 |
+
|
| 390 |
+
def read(self):
|
| 391 |
+
return b"{}"
|
| 392 |
+
|
| 393 |
+
def __enter__(self):
|
| 394 |
+
return self
|
| 395 |
+
|
| 396 |
+
def __exit__(self, *a):
|
| 397 |
+
return False
|
| 398 |
+
|
| 399 |
+
def _fake_urlopen(req, *a, **k):
|
| 400 |
+
return _FakeResp()
|
| 401 |
+
|
| 402 |
+
m._backend_up = lambda: True
|
| 403 |
+
m.urllib.request.urlopen = _fake_urlopen
|
| 404 |
+
try:
|
| 405 |
+
yield
|
| 406 |
+
finally:
|
| 407 |
+
m._backend_up = orig_up
|
| 408 |
+
m.urllib.request.urlopen = orig_urlopen
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
@contextlib.contextmanager
|
| 412 |
+
def _f_t4_5():
|
| 413 |
+
"""Profile/session endpoints return HTTP 500 -> T4.5 FAIL."""
|
| 414 |
+
import audit.tier4_functional as m
|
| 415 |
+
orig_up, orig_get, orig_post = m._backend_up, m._get, m._post
|
| 416 |
+
m._backend_up = lambda: True
|
| 417 |
+
m._get = lambda p, timeout=20: (500, "err")
|
| 418 |
+
m._post = lambda p, payload, timeout=90: (500, "err")
|
| 419 |
+
try:
|
| 420 |
+
yield
|
| 421 |
+
finally:
|
| 422 |
+
m._backend_up, m._get, m._post = orig_up, orig_get, orig_post
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
@contextlib.contextmanager
|
| 426 |
+
def _f_t4_e2e():
|
| 427 |
+
"""Frontend 'reachable' + e2e run reports a failed journey -> T4.E2E FAIL.
|
| 428 |
+
|
| 429 |
+
PW_RUN exists on this machine, so `os.path.exists(PW_RUN)` is already True.
|
| 430 |
+
We patch the module's `urllib.request.urlopen` so the FE :3000 reachability
|
| 431 |
+
probe 'passes' (fake 200) regardless of whether a dev server is up, and
|
| 432 |
+
patch the module's `sh` to return a fake CompletedProcess whose stdout is
|
| 433 |
+
`RJSON {"loads":false}` β a failing journey, which T4.E2E must report as
|
| 434 |
+
FAIL. No real browser/runner is spawned.
|
| 435 |
+
"""
|
| 436 |
+
import subprocess
|
| 437 |
+
import audit.tier4_functional as m
|
| 438 |
+
orig_urlopen = m.urllib.request.urlopen
|
| 439 |
+
orig_sh = m.sh
|
| 440 |
+
|
| 441 |
+
class _FakeResp:
|
| 442 |
+
status = 200
|
| 443 |
+
|
| 444 |
+
def read(self):
|
| 445 |
+
return b"<html><h1>x</h1></html>"
|
| 446 |
+
|
| 447 |
+
def __enter__(self):
|
| 448 |
+
return self
|
| 449 |
+
|
| 450 |
+
def __exit__(self, *a):
|
| 451 |
+
return False
|
| 452 |
+
|
| 453 |
+
m.urllib.request.urlopen = lambda req, *a, **k: _FakeResp()
|
| 454 |
+
m.sh = lambda cmd, timeout=120: subprocess.CompletedProcess(
|
| 455 |
+
cmd, 0, stdout='RJSON {"loads":false}\n', stderr=""
|
| 456 |
+
)
|
| 457 |
+
try:
|
| 458 |
+
yield
|
| 459 |
+
finally:
|
| 460 |
+
m.urllib.request.urlopen = orig_urlopen
|
| 461 |
+
m.sh = orig_sh
|
| 462 |
+
|
| 463 |
+
|
| 464 |
FIXTURES.update({
|
| 465 |
"T1.1": _f_t1_1,
|
| 466 |
"T1.2": _f_t1_2,
|
|
|
|
| 476 |
"T3.1": _f_t3_1,
|
| 477 |
"T3.2": _f_t3_2,
|
| 478 |
"T3.3": _f_t3_3,
|
| 479 |
+
"T4.1": _f_t4_1,
|
| 480 |
+
"T4.2": _f_t4_2,
|
| 481 |
+
"T4.3": _f_t4_3,
|
| 482 |
+
"T4.4": _f_t4_4,
|
| 483 |
+
"T4.5": _f_t4_5,
|
| 484 |
+
"T4.E2E": _f_t4_e2e,
|
| 485 |
})
|
audit/tier4_functional.py
CHANGED
|
@@ -1 +1,147 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tier 4 β functionality: API smoke (deterministic) + Playwright E2E.
|
| 2 |
+
Infra-dependent: SKIPs (never false-PASS, never crash) when the local
|
| 3 |
+
backend / frontend / playwright runner is unavailable."""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
import json, os, time, urllib.request, urllib.error
|
| 6 |
+
from audit.core import register, Result, Status, REPO, sh
|
| 7 |
+
|
| 8 |
+
BK = "http://127.0.0.1:8000"
|
| 9 |
+
FE = "http://localhost:3000"
|
| 10 |
+
PW_RUN = "/Users/rohitsar/.claude/skills/playwright-skill/run.js"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _get(path, timeout=20):
|
| 14 |
+
with urllib.request.urlopen(BK + path, timeout=timeout) as f:
|
| 15 |
+
return f.status, f.read().decode("utf-8", "replace")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _post(path, payload, timeout=90):
|
| 19 |
+
req = urllib.request.Request(
|
| 20 |
+
BK + path, data=json.dumps(payload).encode(),
|
| 21 |
+
headers={"Content-Type": "application/json"}, method="POST")
|
| 22 |
+
with urllib.request.urlopen(req, timeout=timeout) as f:
|
| 23 |
+
return f.status, f.read().decode("utf-8", "replace")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _backend_up():
|
| 27 |
+
try:
|
| 28 |
+
s, b = _get("/api/health", timeout=6)
|
| 29 |
+
return s == 200 and '"status":"ok"' in b
|
| 30 |
+
except Exception:
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@register("T4.1", "functional", "API: health + version")
|
| 35 |
+
def t4_1():
|
| 36 |
+
if not _backend_up():
|
| 37 |
+
return Result("T4.1", Status.SKIP, "no local backend on :8000", "start uvicorn backend.main:app --port 8000")
|
| 38 |
+
try:
|
| 39 |
+
_, hb = _get("/api/health")
|
| 40 |
+
_, vb = _get("/api/version")
|
| 41 |
+
ok = '"status":"ok"' in hb and json.loads(vb) is not None
|
| 42 |
+
return Result("T4.1", Status.PASS if ok else Status.FAIL,
|
| 43 |
+
"health ok + version json" if ok else f"health={hb[:80]} version={vb[:80]}",
|
| 44 |
+
"" if ok else "investigate /api/health or /api/version")
|
| 45 |
+
except Exception as e:
|
| 46 |
+
return Result("T4.1", Status.FAIL, f"{type(e).__name__}: {e}", "endpoint error")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@register("T4.2", "functional", "API: coverage counts sane")
|
| 50 |
+
def t4_2():
|
| 51 |
+
if not _backend_up():
|
| 52 |
+
return Result("T4.2", Status.SKIP, "no local backend", "start backend")
|
| 53 |
+
try:
|
| 54 |
+
_, b = _get("/api/coverage", timeout=40)
|
| 55 |
+
d = json.loads(b)
|
| 56 |
+
p, i, c = d.get("total_policies"), d.get("total_insurers"), d.get("total_chunks")
|
| 57 |
+
ok = isinstance(p, int) and 130 <= p <= 170 and i == 20 and isinstance(c, int) and c > 5000
|
| 58 |
+
return Result("T4.2", Status.PASS if ok else Status.FAIL,
|
| 59 |
+
f"policies={p} insurers={i} chunks={c}",
|
| 60 |
+
"" if ok else "coverage outside expected (~148 policies / 20 insurers / >5000 chunks)")
|
| 61 |
+
except Exception as e:
|
| 62 |
+
return Result("T4.2", Status.FAIL, f"{type(e).__name__}: {e}", "coverage endpoint error")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@register("T4.3", "functional", "API: chat returns a grounded reply")
|
| 66 |
+
def t4_3():
|
| 67 |
+
if not _backend_up():
|
| 68 |
+
return Result("T4.3", Status.SKIP, "no local backend", "start backend")
|
| 69 |
+
try:
|
| 70 |
+
_, b = _post("/api/chat",
|
| 71 |
+
{"user_text": "What is a waiting period in health insurance?",
|
| 72 |
+
"session_id": f"audit-smoke-{int(time.time())}"}, timeout=90)
|
| 73 |
+
d = json.loads(b)
|
| 74 |
+
reply = (d.get("reply_text") or "").strip()
|
| 75 |
+
brain = d.get("brain_used", "")
|
| 76 |
+
if not reply:
|
| 77 |
+
return Result("T4.3", Status.FAIL, f"empty reply (brain={brain})", "chat returned no text")
|
| 78 |
+
if "error_fallback" in brain:
|
| 79 |
+
return Result("T4.3", Status.WARN, f"reply via {brain}", "LLM chain degraded (env/keys) β verify live")
|
| 80 |
+
return Result("T4.3", Status.PASS, f"reply ok ({len(reply)} chars, brain={brain})")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return Result("T4.3", Status.WARN, f"{type(e).__name__}: {e}",
|
| 83 |
+
"chat slow/unavailable (LLM dependency); not a code FAIL on its own")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@register("T4.4", "functional", "API: upload-policy rejects junk")
|
| 87 |
+
def t4_4():
|
| 88 |
+
if not _backend_up():
|
| 89 |
+
return Result("T4.4", Status.SKIP, "no local backend", "start backend")
|
| 90 |
+
boundary = "----auditST"
|
| 91 |
+
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
|
| 92 |
+
f"filename=\"junk.pdf\"\r\nContent-Type: application/pdf\r\n\r\n"
|
| 93 |
+
+ "not a real pdf " * 10 + f"\r\n--{boundary}--\r\n").encode()
|
| 94 |
+
req = urllib.request.Request(BK + "/api/upload-policy", data=body, method="POST",
|
| 95 |
+
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
| 96 |
+
try:
|
| 97 |
+
urllib.request.urlopen(req, timeout=30)
|
| 98 |
+
return Result("T4.4", Status.FAIL, "junk PDF was accepted", "upload security gates not rejecting")
|
| 99 |
+
except urllib.error.HTTPError as e:
|
| 100 |
+
return (Result("T4.4", Status.PASS, f"junk rejected (HTTP {e.code})")
|
| 101 |
+
if e.code in (400, 413, 415, 422)
|
| 102 |
+
else Result("T4.4", Status.FAIL, f"unexpected HTTP {e.code}", "check upload gate"))
|
| 103 |
+
except Exception as e:
|
| 104 |
+
return Result("T4.4", Status.WARN, f"{type(e).__name__}: {e}", "upload endpoint unreachable")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@register("T4.5", "functional", "API: profile + session endpoints")
|
| 108 |
+
def t4_5():
|
| 109 |
+
if not _backend_up():
|
| 110 |
+
return Result("T4.5", Status.SKIP, "no local backend", "start backend")
|
| 111 |
+
sid = f"audit-smoke-{int(time.time())}"
|
| 112 |
+
try:
|
| 113 |
+
s1, _ = _get(f"/api/profile/completeness?session_id={sid}", timeout=20)
|
| 114 |
+
s2, _ = _post("/api/session/clear", {"session_id": sid}, timeout=20)
|
| 115 |
+
ok = s1 == 200 and s2 == 200
|
| 116 |
+
return Result("T4.5", Status.PASS if ok else Status.FAIL,
|
| 117 |
+
f"profile/completeness={s1} session/clear={s2}",
|
| 118 |
+
"" if ok else "profile/session endpoint non-200")
|
| 119 |
+
except Exception as e:
|
| 120 |
+
return Result("T4.5", Status.FAIL, f"{type(e).__name__}: {e}", "profile/session error")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@register("T4.E2E", "functional", "Frontend E2E journeys (Playwright)")
|
| 124 |
+
def t4_e2e():
|
| 125 |
+
if not os.path.exists(PW_RUN):
|
| 126 |
+
return Result("T4.E2E", Status.SKIP, "playwright-skill runner not found",
|
| 127 |
+
"install/locate the playwright-skill to enable E2E")
|
| 128 |
+
try:
|
| 129 |
+
with urllib.request.urlopen(FE, timeout=6) as f:
|
| 130 |
+
if f.status != 200:
|
| 131 |
+
raise RuntimeError("frontend not 200")
|
| 132 |
+
except Exception:
|
| 133 |
+
return Result("T4.E2E", Status.SKIP, "no frontend on :3000",
|
| 134 |
+
"run `cd frontend && npm run dev` to enable E2E")
|
| 135 |
+
r = sh(["node", PW_RUN, str(REPO / "audit/e2e/insurancebot_e2e.js")], timeout=300)
|
| 136 |
+
out = r.stdout + r.stderr
|
| 137 |
+
import re
|
| 138 |
+
m = re.search(r"RJSON (\{.*\})", out)
|
| 139 |
+
if not m:
|
| 140 |
+
return Result("T4.E2E", Status.WARN, "no RJSON from e2e run (infra/flake)",
|
| 141 |
+
"inspect audit/e2e/insurancebot_e2e.js output; not a code FAIL alone")
|
| 142 |
+
d = json.loads(m.group(1))
|
| 143 |
+
fails = [k for k, v in d.items() if v is False]
|
| 144 |
+
if fails:
|
| 145 |
+
return Result("T4.E2E", Status.FAIL, f"E2E journey failures: {fails}",
|
| 146 |
+
"investigate the failing UI journey")
|
| 147 |
+
return Result("T4.E2E", Status.PASS, f"E2E ok: {sorted(d)}")
|