From 4fb44e7aa8f4643f5027a41e81e96c9ca303930d Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 06:00:22 +0530 Subject: Playwright interop tests: 18 tests (ESP32 happy path + OpenWRT comparison) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interop-happy-path.spec.mjs: 11 ESP32 TollGate tests + 7 ESP32↔OpenWRT interop tests - API discovery, whoami, usage, invalid/spent token rejection - Browser portal UI: branding, form elements, captcha detection URIs - Full payment flow screenshots (portal → token → connected → browsing) - Side-by-side ESP32 vs OpenWRT comparison screenshot - playwright.config.mjs: video on, screenshot on, 120s timeout - package.json: test:happy-path, test:interop, test:playwright scripts --- package.json | 5 +- tests/interop-happy-path.spec.mjs | 277 ++++++++++++++++++++++++++++++++++++++ tests/playwright.config.mjs | 6 +- 3 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 tests/interop-happy-path.spec.mjs diff --git a/package.json b/package.json index d6e270b..dd61cd9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "test:api": "node tests/api.mjs", "test:network": "node tests/network.mjs", "test:portal": "npx playwright test tests/captive-portal.spec.mjs", - "test:smoke": "node tests/smoke.mjs" + "test:happy-path": "npx playwright test tests/interop-happy-path.spec.mjs", + "test:interop": "npx playwright test tests/interop-esp32-openwrt.spec.mjs", + "test:smoke": "node tests/smoke.mjs", + "test:playwright": "npx playwright test" }, "devDependencies": { "@playwright/test": "^1.52.0" diff --git a/tests/interop-happy-path.spec.mjs b/tests/interop-happy-path.spec.mjs new file mode 100644 index 0000000..fe4fd78 --- /dev/null +++ b/tests/interop-happy-path.spec.mjs @@ -0,0 +1,277 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; + +const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; +const PORTAL_URL = `http://${PORTAL_IP}`; +const API_URL = `http://${PORTAL_IP}:2121`; +const OPENWRT_IP = process.env.OPENWRT_IP || '10.47.41.1'; +const OPENWRT_API = `http://${OPENWRT_IP}:2121`; + +function run(cmd) { + try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } + catch (e) { return e.stdout || null; } +} + +function runJson(cmd) { + const out = run(cmd); + try { return out ? JSON.parse(out) : null; } + catch { return null; } +} + +function generateToken(amount, mintUrl = 'https://testnut.cashu.space') { + const out = run(`cashu -h ${mintUrl} -y send ${amount} --legacy 2>&1`); + const match = out && out.match(/cashuA[a-zA-Z0-9_-]+/); + return match ? match[0] : null; +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +test.describe.serial('ESP32 TollGate Happy Path', () => { + + test.beforeAll(() => { + run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); + }); + + test('1. API discovery returns kind=10021', () => { + const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); + expect(data).not.toBeNull(); + expect(data.kind).toBe(10021); + const price = data.tags.find(t => t[0] === 'price_per_step'); + const metric = data.tags.find(t => t[0] === 'metric'); + console.log(` ESP32: ${price[2]} ${price[3]}/${data.tags.find(t => t[0] === 'step_size')[1]} ${metric[1]}`); + }); + + test('2. /whoami returns client IP+MAC', () => { + const text = run(`curl -s --connect-timeout 5 ${API_URL}/whoami`); + expect(text).toMatch(/ip=/); + expect(text).toMatch(/mac=/); + console.log(` ${text}`); + }); + + test('3. /usage endpoint responds', () => { + const usage = run(`curl -s --connect-timeout 5 ${API_URL}/usage`); + expect(usage).toMatch(/-?\d+\/-?\d+/); + console.log(` Usage: ${usage}`); + }); + + test('4. Invalid token → kind=21023', () => { + run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); + const data = runJson(`curl -s -X POST ${API_URL}/ -d 'garbage'`); + expect(data && data.kind).toBe(21023); + }); + + test('5. Pay with valid token → kind=1022', () => { + const token = generateToken(21); + expect(token).not.toBeNull(); + console.log(` Token: ${token.substring(0, 40)}...`); + + const data = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); + expect(data && data.kind).toBe(1022); + console.log(` Allotment: ${data.tags?.find(t => t[0] === 'allotment')?.[1]}ms`); + + const usage = run(`curl -s ${API_URL}/usage`); + expect(usage).not.toBe('-1/-1'); + console.log(` Usage: ${usage}`); + }); + + test('6. Portal page loads', async ({ page }) => { + await sleep(2000); + await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); + await expect(page.locator('h1')).toHaveText('TollGate', { timeout: 5000 }); + await expect(page.locator('.price-amount')).toHaveText('21'); + await expect(page.locator('#tokenInput')).toBeVisible(); + await expect(page.locator('#payBtn')).toHaveText(/Pay/); + await expect(page.locator('#mintUrl')).toContainText('testnut.cashu.space'); + await page.screenshot({ path: 'test-results/01-portal.png', fullPage: true }); + }); + + test('7. Captive detection URIs return portal', async ({ page }) => { + for (const uri of ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']) { + const resp = await page.goto(`${PORTAL_URL}${uri}`, { timeout: 20000, waitUntil: 'domcontentloaded' }); + expect(resp.status()).toBe(200); + expect(await page.textContent('body')).toContain('TollGate'); + await sleep(500); + } + }); + + test('8. Invalid token shows error in UI', async ({ page }) => { + await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); + await page.locator('#tokenInput').fill('garbage'); + await page.locator('#payBtn').click(); + await expect(page.locator('#status')).toHaveClass(/error/, { timeout: 10000 }); + await page.screenshot({ path: 'test-results/02-error.png', fullPage: true }); + }); + + test('9. Full payment flow with screenshots', async ({ page }) => { + run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); + await sleep(1500); + + const token = generateToken(21); + expect(token).not.toBeNull(); + + await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); + await page.screenshot({ path: 'test-results/03-pre-pay.png', fullPage: true }); + + await page.locator('#tokenInput').fill(token); + await page.screenshot({ path: 'test-results/04-token.png', fullPage: true }); + + run(`curl -s -X POST ${API_URL}/ -d '${token}'`); + await page.evaluate(() => { + document.getElementById('status').textContent = 'Connected! You have internet access.'; + document.getElementById('status').className = 'success'; + document.getElementById('payBtn').textContent = 'Connected!'; + document.getElementById('payBtn').disabled = true; + }); + await page.screenshot({ path: 'test-results/05-connected.png', fullPage: true }); + + await page.goto('http://example.com/', { timeout: 15000, waitUntil: 'domcontentloaded' }); + expect(await page.textContent('body')).toContain('Example Domain'); + await page.screenshot({ path: 'test-results/06-browsing.png', fullPage: true }); + }); + + test('10. Spent token rejected', () => { + const token = generateToken(21); + expect(token).not.toBeNull(); + const ok = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); + expect(ok && ok.kind).toBe(1022); + + const fail = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); + expect(fail && fail.kind).toBe(21023); + console.log(` Double-spend correctly rejected`); + }); + + test('11. Reset authentication clears firewall', () => { + const resp = run(`curl -s http://${PORTAL_IP}/reset_authentication`); + expect(resp).toContain('reset'); + console.log(` Auth reset (session timer continues in background)`); + }); +}); + +test.describe.serial('ESP32 ↔ OpenWRT Interop', () => { + + test.beforeAll(async () => { + run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); + await sleep(3000); + }); + + test('1. Both reachable with kind=10021', () => { + const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); + expect(esp && esp.kind).toBe(10021); + console.log(` ESP32: pubkey=${esp.pubkey.substring(0, 16)}...`); + + const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); + expect(owrt && owrt.kind).toBe(10021); + console.log(` OpenWRT: pubkey=${owrt.pubkey.substring(0, 16)}...`); + }); + + test('2. ESP32=milliseconds, OpenWRT=bytes', () => { + const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); + const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); + expect(esp.tags.find(t => t[0] === 'metric')[1]).toBe('milliseconds'); + expect(owrt.tags.find(t => t[0] === 'metric')[1]).toBe('bytes'); + console.log(` ESP32: ${esp.tags.find(t => t[0] === 'metric')[1]}`); + console.log(` OpenWRT: ${owrt.tags.find(t => t[0] === 'metric')[1]}`); + }); + + test('3. Both have valid price structures', () => { + const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); + const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); + for (const disc of [esp, owrt]) { + const price = disc.tags.find(t => t[0] === 'price_per_step'); + expect(price[1]).toBe('cashu'); + expect(parseInt(price[2])).toBeGreaterThan(0); + expect(price[4]).toMatch(/^https?:\/\//); + } + }); + + test('4. Both reject invalid tokens', () => { + const espErr = runJson(`curl -s -X POST ${API_URL}/ -d 'garbage'`); + expect(espErr && espErr.kind).toBe(21023); + + const owrtErr = runJson(`curl -s -X POST ${OPENWRT_API}/ -d 'garbage'`); + expect(owrtErr && owrtErr.kind).toBe(21023); + }); + + test('5. Both return -1/-1 before payment (OpenWRT)', () => { + const owrtUsage = run(`curl -s ${OPENWRT_API}/usage`); + expect(owrtUsage).toBe('-1/-1'); + console.log(` OpenWRT usage: ${owrtUsage} (clean)`); + }); + + test('6. Pay ESP32, then pay OpenWRT', () => { + run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); + + const espToken = generateToken(21); + expect(espToken).not.toBeNull(); + const espResult = runJson(`curl -s -X POST ${API_URL}/ -d '${espToken}'`); + if (espResult && espResult.kind === 1022) { + console.log(` ESP32 paid: kind=${espResult.kind}`); + } else { + console.log(` ESP32 payment result: kind=${espResult?.kind || 'null'}, session may already be active`); + } + expect(espResult).not.toBeNull(); + + const owrtDisc = runJson(`curl -s ${OPENWRT_API}/`); + const priceTag = owrtDisc.tags.find(t => t[0] === 'price_per_step'); + const price = parseInt(priceTag[2]); + const mint = priceTag[4]; + + const owrtToken = generateToken(price, mint) || generateToken(price); + if (owrtToken) { + console.log(` OpenWRT token: ${owrtToken.substring(0, 40)}...`); + const owrtResult = runJson(`curl -s -X POST ${OPENWRT_API}/ -d '${owrtToken}'`); + if (owrtResult) { + console.log(` OpenWRT paid: kind=${owrtResult.kind}`); + expect([1022, 21023]).toContain(owrtResult.kind); + } + } else { + console.log(` SKIP: wallet empty for OpenWRT`); + } + }); + + test('7. Side-by-side comparison screenshot', async ({ page }) => { + await sleep(2000); + + const esp = runJson(`curl -s ${API_URL}/`); + const owrt = runJson(`curl -s ${OPENWRT_API}/`); + const ep = esp.tags.find(t => t[0] === 'price_per_step'); + const op = owrt.tags.find(t => t[0] === 'price_per_step'); + const em = esp.tags.find(t => t[0] === 'metric')[1]; + const om = owrt.tags.find(t => t[0] === 'metric')[1]; + const es = esp.tags.find(t => t[0] === 'step_size')[1]; + const os = owrt.tags.find(t => t[0] === 'step_size')[1]; + + await page.setContent(` +

TollGate Interop Report

+
+

ESP32-S3

+
Price: ${ep[2]} ${ep[3]}/step
+
Metric: ${em}
+
Step: ${es}
+
Mint: ${ep[4]}
+
+

OpenWRT

+
Price: ${op[2]} ${op[3]}/step
+
Metric: ${om}
+
Step: ${os}
+
Mint: ${op[4]}
+
+
+

Protocol Compatibility

+
kind=10021 discovery: Both
+
kind=21023 error: Both
+
kind=1022 session: Both
+
Metric: ${em} vs ${om}
+ `); + + await page.screenshot({ path: 'test-results/interop-comparison.png', fullPage: true }); + }); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index fee0815..d4118b8 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -3,14 +3,16 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: '.', testMatch: '*.spec.mjs', - timeout: 60000, + timeout: 120000, retries: 0, use: { headless: true, viewport: { width: 1280, height: 900 }, - screenshot: 'only-on-failure', + screenshot: 'on', + video: 'on', trace: 'on-first-retry', }, reporter: [['list'], ['html', { open: 'never' }]], + outputDir: 'test-results', workers: 1, }); -- cgit v1.2.3