diff options
| author | Your Name <you@example.com> | 2026-05-17 17:18:43 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-17 17:18:43 +0530 |
| commit | 8071741815f0b0938701e80a63e80b0ec94b2778 (patch) | |
| tree | 2a1511480e0b58f4efb144aa9d10c9fba5eed034 /tests/e2e | |
| parent | 0c2c67b463d6a90aaa0bb69bf3c91dba1d9ec3ec (diff) | |
refactor: reorganize test suite, add integration tests for NAT filter
- Move integration tests (api, network, phase2, smoke) to tests/integration/
- Move Playwright specs (captive-portal, interop-happy-path) to tests/e2e/
- Move playwright.config.mjs to tests/e2e/
- Fix hardcoded IP fallbacks: 192.168.4.1 → 10.192.45.1
- Add test-reset-auth.mjs: reset→pay→allow→revoke→block cycle
- Add test-session-expiry.mjs: pay→wait 65s→verify blocked (slow test)
- Add test-dns-firewall.mjs: DNS hijack/forward + per-client NAT filter
- Update Makefile with test-unit, test-integration, test-e2e, test-all targets
- Update package.json scripts for new paths
- Fix Playwright video: retain-on-failure instead of always-on
- Update AGENTS.md: per-client NAT filter description
- Update CHECKLIST.md: mark completed items, add Board B identity
- Board B nsec: 9af47906... → SSID TollGate-b96d80, AP IP 10.185.47.1
- 186 unit tests passing
Diffstat (limited to 'tests/e2e')
| -rw-r--r-- | tests/e2e/captive-portal.spec.mjs | 118 | ||||
| -rw-r--r-- | tests/e2e/interop-happy-path.spec.mjs | 277 | ||||
| -rw-r--r-- | tests/e2e/playwright.config.mjs | 18 |
3 files changed, 413 insertions, 0 deletions
diff --git a/tests/e2e/captive-portal.spec.mjs b/tests/e2e/captive-portal.spec.mjs new file mode 100644 index 0000000..ab9d4f1 --- /dev/null +++ b/tests/e2e/captive-portal.spec.mjs | |||
| @@ -0,0 +1,118 @@ | |||
| 1 | import { test, expect } from '@playwright/test'; | ||
| 2 | |||
| 3 | const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const PORTAL_URL = `http://${PORTAL_IP}`; | ||
| 5 | const API_URL = `http://${PORTAL_IP}:2121`; | ||
| 6 | |||
| 7 | test.describe('Captive Portal - Phase 2', () => { | ||
| 8 | |||
| 9 | test('portal page loads with TollGate branding', async ({ page }) => { | ||
| 10 | await page.goto(PORTAL_URL); | ||
| 11 | await expect(page.locator('h1')).toHaveText('TollGate'); | ||
| 12 | await expect(page.locator('.subtitle')).toContainText('internet access'); | ||
| 13 | }); | ||
| 14 | |||
| 15 | test('portal shows price from API', async ({ page }) => { | ||
| 16 | await page.goto(PORTAL_URL); | ||
| 17 | const priceEl = page.locator('.price-amount'); | ||
| 18 | await expect(priceEl).toHaveText(/\d+/, { timeout: 5000 }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | test('portal embeds mint URL without JavaScript fetch', async ({ request }) => { | ||
| 22 | const resp = await request.fetch(PORTAL_URL); | ||
| 23 | const body = await resp.text(); | ||
| 24 | expect(body).not.toContain('Loading...'); | ||
| 25 | expect(body).not.toContain('Error loading mint URL'); | ||
| 26 | expect(body).toMatch(/testnut\.cashu\.space/); | ||
| 27 | }); | ||
| 28 | |||
| 29 | test('portal embeds price without JavaScript fetch', async ({ request }) => { | ||
| 30 | const resp = await request.fetch(PORTAL_URL); | ||
| 31 | const body = await resp.text(); | ||
| 32 | expect(body).not.toContain('__PRICE__'); | ||
| 33 | expect(body).toMatch(/price-amount['"]>\d+</); | ||
| 34 | }); | ||
| 35 | |||
| 36 | test('portal HTML has no unresolved template placeholders', async ({ request }) => { | ||
| 37 | const resp = await request.fetch(PORTAL_URL); | ||
| 38 | const body = await resp.text(); | ||
| 39 | expect(body).not.toContain('__AP_IP__'); | ||
| 40 | expect(body).not.toContain('__MINT_URL__'); | ||
| 41 | expect(body).not.toContain('__PRICE__'); | ||
| 42 | }); | ||
| 43 | |||
| 44 | test('mints section appears after token input in DOM order', async ({ page }) => { | ||
| 45 | await page.goto(PORTAL_URL); | ||
| 46 | const textarea = page.locator('#tokenInput'); | ||
| 47 | const mintUrl = page.locator('#mintUrl'); | ||
| 48 | await expect(textarea).toBeVisible(); | ||
| 49 | await expect(mintUrl).toBeVisible(); | ||
| 50 | const inputBox = await textarea.boundingBox(); | ||
| 51 | const mintBox = await mintUrl.boundingBox(); | ||
| 52 | expect(mintBox.y).toBeGreaterThan(inputBox.y); | ||
| 53 | }); | ||
| 54 | |||
| 55 | test('portal has Cashu token input', async ({ page }) => { | ||
| 56 | await page.goto(PORTAL_URL); | ||
| 57 | const textarea = page.locator('#tokenInput'); | ||
| 58 | await expect(textarea).toBeVisible(); | ||
| 59 | await expect(textarea).toHaveAttribute('placeholder', /cashuA/); | ||
| 60 | }); | ||
| 61 | |||
| 62 | test('portal has Pay & Connect button', async ({ page }) => { | ||
| 63 | await page.goto(PORTAL_URL); | ||
| 64 | const btn = page.locator('#payBtn'); | ||
| 65 | await expect(btn).toBeVisible(); | ||
| 66 | await expect(btn).toHaveText(/Pay/); | ||
| 67 | }); | ||
| 68 | |||
| 69 | test('captive detection URIs return portal HTML (200)', async ({ request }) => { | ||
| 70 | const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']; | ||
| 71 | for (const uri of uris) { | ||
| 72 | const resp = await request.fetch(`${PORTAL_URL}${uri}`); | ||
| 73 | expect(resp.status()).toBe(200); | ||
| 74 | const body = await resp.text(); | ||
| 75 | expect(body).toContain('TollGate'); | ||
| 76 | } | ||
| 77 | }); | ||
| 78 | |||
| 79 | test('catch-all URIs redirect to portal page', async ({ page }) => { | ||
| 80 | await page.goto(`${PORTAL_URL}/some-random-page`); | ||
| 81 | await expect(page.locator('h1')).toHaveText('TollGate'); | ||
| 82 | }); | ||
| 83 | |||
| 84 | test('/whoami returns ip and mac', async ({ page }) => { | ||
| 85 | const resp = await page.goto(`${API_URL}/whoami`); | ||
| 86 | expect(resp.status()).toBe(200); | ||
| 87 | const text = await resp.text(); | ||
| 88 | expect(text).toMatch(/ip=\d+\.\d+\.\d+\.\d+/); | ||
| 89 | expect(text).toMatch(/mac=(unknown|[0-9a-f]{2}:)/); | ||
| 90 | }); | ||
| 91 | |||
| 92 | test('/usage returns -1/-1 before payment', async ({ page }) => { | ||
| 93 | const resp = await page.goto(`${API_URL}/usage`); | ||
| 94 | expect(resp.status()).toBe(200); | ||
| 95 | const text = await resp.text(); | ||
| 96 | expect(text).toBe('-1/-1'); | ||
| 97 | }); | ||
| 98 | |||
| 99 | test('API advertisement has correct structure', async ({ page }) => { | ||
| 100 | const resp = await page.goto(API_URL); | ||
| 101 | expect(resp.status()).toBe(200); | ||
| 102 | const data = await resp.json(); | ||
| 103 | expect(data.kind).toBe(10021); | ||
| 104 | expect(data.tags).toBeDefined(); | ||
| 105 | expect(data.tags.some(t => t[0] === 'price_per_step')).toBe(true); | ||
| 106 | expect(data.tags.some(t => t[0] === 'step_size')).toBe(true); | ||
| 107 | }); | ||
| 108 | |||
| 109 | test('invalid token returns error', async ({ request }) => { | ||
| 110 | const resp = await request.fetch(API_URL, { | ||
| 111 | method: 'POST', | ||
| 112 | data: 'garbage_not_a_token' | ||
| 113 | }); | ||
| 114 | expect(resp.status()).toBe(400); | ||
| 115 | const data = await resp.json(); | ||
| 116 | expect(data.kind).toBe(21023); | ||
| 117 | }); | ||
| 118 | }); | ||
diff --git a/tests/e2e/interop-happy-path.spec.mjs b/tests/e2e/interop-happy-path.spec.mjs new file mode 100644 index 0000000..fe4fd78 --- /dev/null +++ b/tests/e2e/interop-happy-path.spec.mjs | |||
| @@ -0,0 +1,277 @@ | |||
| 1 | import { test, expect } from '@playwright/test'; | ||
| 2 | import { execSync } from 'child_process'; | ||
| 3 | |||
| 4 | const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 5 | const PORTAL_URL = `http://${PORTAL_IP}`; | ||
| 6 | const API_URL = `http://${PORTAL_IP}:2121`; | ||
| 7 | const OPENWRT_IP = process.env.OPENWRT_IP || '10.47.41.1'; | ||
| 8 | const OPENWRT_API = `http://${OPENWRT_IP}:2121`; | ||
| 9 | |||
| 10 | function run(cmd) { | ||
| 11 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 12 | catch (e) { return e.stdout || null; } | ||
| 13 | } | ||
| 14 | |||
| 15 | function runJson(cmd) { | ||
| 16 | const out = run(cmd); | ||
| 17 | try { return out ? JSON.parse(out) : null; } | ||
| 18 | catch { return null; } | ||
| 19 | } | ||
| 20 | |||
| 21 | function generateToken(amount, mintUrl = 'https://testnut.cashu.space') { | ||
| 22 | const out = run(`cashu -h ${mintUrl} -y send ${amount} --legacy 2>&1`); | ||
| 23 | const match = out && out.match(/cashuA[a-zA-Z0-9_-]+/); | ||
| 24 | return match ? match[0] : null; | ||
| 25 | } | ||
| 26 | |||
| 27 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 28 | |||
| 29 | test.describe.serial('ESP32 TollGate Happy Path', () => { | ||
| 30 | |||
| 31 | test.beforeAll(() => { | ||
| 32 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 33 | }); | ||
| 34 | |||
| 35 | test('1. API discovery returns kind=10021', () => { | ||
| 36 | const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 37 | expect(data).not.toBeNull(); | ||
| 38 | expect(data.kind).toBe(10021); | ||
| 39 | const price = data.tags.find(t => t[0] === 'price_per_step'); | ||
| 40 | const metric = data.tags.find(t => t[0] === 'metric'); | ||
| 41 | console.log(` ESP32: ${price[2]} ${price[3]}/${data.tags.find(t => t[0] === 'step_size')[1]} ${metric[1]}`); | ||
| 42 | }); | ||
| 43 | |||
| 44 | test('2. /whoami returns client IP+MAC', () => { | ||
| 45 | const text = run(`curl -s --connect-timeout 5 ${API_URL}/whoami`); | ||
| 46 | expect(text).toMatch(/ip=/); | ||
| 47 | expect(text).toMatch(/mac=/); | ||
| 48 | console.log(` ${text}`); | ||
| 49 | }); | ||
| 50 | |||
| 51 | test('3. /usage endpoint responds', () => { | ||
| 52 | const usage = run(`curl -s --connect-timeout 5 ${API_URL}/usage`); | ||
| 53 | expect(usage).toMatch(/-?\d+\/-?\d+/); | ||
| 54 | console.log(` Usage: ${usage}`); | ||
| 55 | }); | ||
| 56 | |||
| 57 | test('4. Invalid token → kind=21023', () => { | ||
| 58 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 59 | const data = runJson(`curl -s -X POST ${API_URL}/ -d 'garbage'`); | ||
| 60 | expect(data && data.kind).toBe(21023); | ||
| 61 | }); | ||
| 62 | |||
| 63 | test('5. Pay with valid token → kind=1022', () => { | ||
| 64 | const token = generateToken(21); | ||
| 65 | expect(token).not.toBeNull(); | ||
| 66 | console.log(` Token: ${token.substring(0, 40)}...`); | ||
| 67 | |||
| 68 | const data = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 69 | expect(data && data.kind).toBe(1022); | ||
| 70 | console.log(` Allotment: ${data.tags?.find(t => t[0] === 'allotment')?.[1]}ms`); | ||
| 71 | |||
| 72 | const usage = run(`curl -s ${API_URL}/usage`); | ||
| 73 | expect(usage).not.toBe('-1/-1'); | ||
| 74 | console.log(` Usage: ${usage}`); | ||
| 75 | }); | ||
| 76 | |||
| 77 | test('6. Portal page loads', async ({ page }) => { | ||
| 78 | await sleep(2000); | ||
| 79 | await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 80 | await expect(page.locator('h1')).toHaveText('TollGate', { timeout: 5000 }); | ||
| 81 | await expect(page.locator('.price-amount')).toHaveText('21'); | ||
| 82 | await expect(page.locator('#tokenInput')).toBeVisible(); | ||
| 83 | await expect(page.locator('#payBtn')).toHaveText(/Pay/); | ||
| 84 | await expect(page.locator('#mintUrl')).toContainText('testnut.cashu.space'); | ||
| 85 | await page.screenshot({ path: 'test-results/01-portal.png', fullPage: true }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | test('7. Captive detection URIs return portal', async ({ page }) => { | ||
| 89 | for (const uri of ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']) { | ||
| 90 | const resp = await page.goto(`${PORTAL_URL}${uri}`, { timeout: 20000, waitUntil: 'domcontentloaded' }); | ||
| 91 | expect(resp.status()).toBe(200); | ||
| 92 | expect(await page.textContent('body')).toContain('TollGate'); | ||
| 93 | await sleep(500); | ||
| 94 | } | ||
| 95 | }); | ||
| 96 | |||
| 97 | test('8. Invalid token shows error in UI', async ({ page }) => { | ||
| 98 | await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 99 | await page.locator('#tokenInput').fill('garbage'); | ||
| 100 | await page.locator('#payBtn').click(); | ||
| 101 | await expect(page.locator('#status')).toHaveClass(/error/, { timeout: 10000 }); | ||
| 102 | await page.screenshot({ path: 'test-results/02-error.png', fullPage: true }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | test('9. Full payment flow with screenshots', async ({ page }) => { | ||
| 106 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 107 | await sleep(1500); | ||
| 108 | |||
| 109 | const token = generateToken(21); | ||
| 110 | expect(token).not.toBeNull(); | ||
| 111 | |||
| 112 | await page.goto(PORTAL_URL, { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 113 | await page.screenshot({ path: 'test-results/03-pre-pay.png', fullPage: true }); | ||
| 114 | |||
| 115 | await page.locator('#tokenInput').fill(token); | ||
| 116 | await page.screenshot({ path: 'test-results/04-token.png', fullPage: true }); | ||
| 117 | |||
| 118 | run(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 119 | await page.evaluate(() => { | ||
| 120 | document.getElementById('status').textContent = 'Connected! You have internet access.'; | ||
| 121 | document.getElementById('status').className = 'success'; | ||
| 122 | document.getElementById('payBtn').textContent = 'Connected!'; | ||
| 123 | document.getElementById('payBtn').disabled = true; | ||
| 124 | }); | ||
| 125 | await page.screenshot({ path: 'test-results/05-connected.png', fullPage: true }); | ||
| 126 | |||
| 127 | await page.goto('http://example.com/', { timeout: 15000, waitUntil: 'domcontentloaded' }); | ||
| 128 | expect(await page.textContent('body')).toContain('Example Domain'); | ||
| 129 | await page.screenshot({ path: 'test-results/06-browsing.png', fullPage: true }); | ||
| 130 | }); | ||
| 131 | |||
| 132 | test('10. Spent token rejected', () => { | ||
| 133 | const token = generateToken(21); | ||
| 134 | expect(token).not.toBeNull(); | ||
| 135 | const ok = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 136 | expect(ok && ok.kind).toBe(1022); | ||
| 137 | |||
| 138 | const fail = runJson(`curl -s -X POST ${API_URL}/ -d '${token}'`); | ||
| 139 | expect(fail && fail.kind).toBe(21023); | ||
| 140 | console.log(` Double-spend correctly rejected`); | ||
| 141 | }); | ||
| 142 | |||
| 143 | test('11. Reset authentication clears firewall', () => { | ||
| 144 | const resp = run(`curl -s http://${PORTAL_IP}/reset_authentication`); | ||
| 145 | expect(resp).toContain('reset'); | ||
| 146 | console.log(` Auth reset (session timer continues in background)`); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | test.describe.serial('ESP32 ↔ OpenWRT Interop', () => { | ||
| 151 | |||
| 152 | test.beforeAll(async () => { | ||
| 153 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 154 | await sleep(3000); | ||
| 155 | }); | ||
| 156 | |||
| 157 | test('1. Both reachable with kind=10021', () => { | ||
| 158 | const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 159 | expect(esp && esp.kind).toBe(10021); | ||
| 160 | console.log(` ESP32: pubkey=${esp.pubkey.substring(0, 16)}...`); | ||
| 161 | |||
| 162 | const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); | ||
| 163 | expect(owrt && owrt.kind).toBe(10021); | ||
| 164 | console.log(` OpenWRT: pubkey=${owrt.pubkey.substring(0, 16)}...`); | ||
| 165 | }); | ||
| 166 | |||
| 167 | test('2. ESP32=milliseconds, OpenWRT=bytes', () => { | ||
| 168 | const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 169 | const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); | ||
| 170 | expect(esp.tags.find(t => t[0] === 'metric')[1]).toBe('milliseconds'); | ||
| 171 | expect(owrt.tags.find(t => t[0] === 'metric')[1]).toBe('bytes'); | ||
| 172 | console.log(` ESP32: ${esp.tags.find(t => t[0] === 'metric')[1]}`); | ||
| 173 | console.log(` OpenWRT: ${owrt.tags.find(t => t[0] === 'metric')[1]}`); | ||
| 174 | }); | ||
| 175 | |||
| 176 | test('3. Both have valid price structures', () => { | ||
| 177 | const esp = runJson(`curl -s --connect-timeout 5 ${API_URL}/`); | ||
| 178 | const owrt = runJson(`curl -s --connect-timeout 5 ${OPENWRT_API}/`); | ||
| 179 | for (const disc of [esp, owrt]) { | ||
| 180 | const price = disc.tags.find(t => t[0] === 'price_per_step'); | ||
| 181 | expect(price[1]).toBe('cashu'); | ||
| 182 | expect(parseInt(price[2])).toBeGreaterThan(0); | ||
| 183 | expect(price[4]).toMatch(/^https?:\/\//); | ||
| 184 | } | ||
| 185 | }); | ||
| 186 | |||
| 187 | test('4. Both reject invalid tokens', () => { | ||
| 188 | const espErr = runJson(`curl -s -X POST ${API_URL}/ -d 'garbage'`); | ||
| 189 | expect(espErr && espErr.kind).toBe(21023); | ||
| 190 | |||
| 191 | const owrtErr = runJson(`curl -s -X POST ${OPENWRT_API}/ -d 'garbage'`); | ||
| 192 | expect(owrtErr && owrtErr.kind).toBe(21023); | ||
| 193 | }); | ||
| 194 | |||
| 195 | test('5. Both return -1/-1 before payment (OpenWRT)', () => { | ||
| 196 | const owrtUsage = run(`curl -s ${OPENWRT_API}/usage`); | ||
| 197 | expect(owrtUsage).toBe('-1/-1'); | ||
| 198 | console.log(` OpenWRT usage: ${owrtUsage} (clean)`); | ||
| 199 | }); | ||
| 200 | |||
| 201 | test('6. Pay ESP32, then pay OpenWRT', () => { | ||
| 202 | run(`curl -s http://${PORTAL_IP}/reset_authentication 2>/dev/null`); | ||
| 203 | |||
| 204 | const espToken = generateToken(21); | ||
| 205 | expect(espToken).not.toBeNull(); | ||
| 206 | const espResult = runJson(`curl -s -X POST ${API_URL}/ -d '${espToken}'`); | ||
| 207 | if (espResult && espResult.kind === 1022) { | ||
| 208 | console.log(` ESP32 paid: kind=${espResult.kind}`); | ||
| 209 | } else { | ||
| 210 | console.log(` ESP32 payment result: kind=${espResult?.kind || 'null'}, session may already be active`); | ||
| 211 | } | ||
| 212 | expect(espResult).not.toBeNull(); | ||
| 213 | |||
| 214 | const owrtDisc = runJson(`curl -s ${OPENWRT_API}/`); | ||
| 215 | const priceTag = owrtDisc.tags.find(t => t[0] === 'price_per_step'); | ||
| 216 | const price = parseInt(priceTag[2]); | ||
| 217 | const mint = priceTag[4]; | ||
| 218 | |||
| 219 | const owrtToken = generateToken(price, mint) || generateToken(price); | ||
| 220 | if (owrtToken) { | ||
| 221 | console.log(` OpenWRT token: ${owrtToken.substring(0, 40)}...`); | ||
| 222 | const owrtResult = runJson(`curl -s -X POST ${OPENWRT_API}/ -d '${owrtToken}'`); | ||
| 223 | if (owrtResult) { | ||
| 224 | console.log(` OpenWRT paid: kind=${owrtResult.kind}`); | ||
| 225 | expect([1022, 21023]).toContain(owrtResult.kind); | ||
| 226 | } | ||
| 227 | } else { | ||
| 228 | console.log(` SKIP: wallet empty for OpenWRT`); | ||
| 229 | } | ||
| 230 | }); | ||
| 231 | |||
| 232 | test('7. Side-by-side comparison screenshot', async ({ page }) => { | ||
| 233 | await sleep(2000); | ||
| 234 | |||
| 235 | const esp = runJson(`curl -s ${API_URL}/`); | ||
| 236 | const owrt = runJson(`curl -s ${OPENWRT_API}/`); | ||
| 237 | const ep = esp.tags.find(t => t[0] === 'price_per_step'); | ||
| 238 | const op = owrt.tags.find(t => t[0] === 'price_per_step'); | ||
| 239 | const em = esp.tags.find(t => t[0] === 'metric')[1]; | ||
| 240 | const om = owrt.tags.find(t => t[0] === 'metric')[1]; | ||
| 241 | const es = esp.tags.find(t => t[0] === 'step_size')[1]; | ||
| 242 | const os = owrt.tags.find(t => t[0] === 'step_size')[1]; | ||
| 243 | |||
| 244 | await page.setContent(`<!DOCTYPE html><html><head><style> | ||
| 245 | body{font-family:monospace;background:#0a0a0a;color:#fff;padding:40px} | ||
| 246 | h1{color:#f7931a}h2{color:#888;margin-top:30px} | ||
| 247 | .grid{display:grid;grid-template-columns:1fr 1fr;gap:30px;max-width:900px} | ||
| 248 | .card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:24px} | ||
| 249 | .label{color:#888;font-size:12px}.value{color:#f7931a;font-size:18px;font-weight:bold} | ||
| 250 | .tag{background:#252525;padding:8px 12px;border-radius:6px;margin:4px 0;font-size:13px} | ||
| 251 | .ok{color:#4caf50}.diff{color:#f44336}h3{color:#2196f3;margin-top:20px} | ||
| 252 | </style></head><body> | ||
| 253 | <h1>TollGate Interop Report</h1> | ||
| 254 | <div class="grid"> | ||
| 255 | <div class="card"><h2>ESP32-S3</h2> | ||
| 256 | <div class="tag"><span class="label">Price:</span> <span class="value">${ep[2]} ${ep[3]}/step</span></div> | ||
| 257 | <div class="tag"><span class="label">Metric:</span> <span class="value">${em}</span></div> | ||
| 258 | <div class="tag"><span class="label">Step:</span> <span class="value">${es}</span></div> | ||
| 259 | <div class="tag"><span class="label">Mint:</span> ${ep[4]}</div> | ||
| 260 | </div> | ||
| 261 | <div class="card"><h2>OpenWRT</h2> | ||
| 262 | <div class="tag"><span class="label">Price:</span> <span class="value">${op[2]} ${op[3]}/step</span></div> | ||
| 263 | <div class="tag"><span class="label">Metric:</span> <span class="value">${om}</span></div> | ||
| 264 | <div class="tag"><span class="label">Step:</span> <span class="value">${os}</span></div> | ||
| 265 | <div class="tag"><span class="label">Mint:</span> ${op[4]}</div> | ||
| 266 | </div> | ||
| 267 | </div> | ||
| 268 | <h3>Protocol Compatibility</h3> | ||
| 269 | <div class="tag ok">kind=10021 discovery: Both</div> | ||
| 270 | <div class="tag ok">kind=21023 error: Both</div> | ||
| 271 | <div class="tag ok">kind=1022 session: Both</div> | ||
| 272 | <div class="tag ${em !== om ? 'diff' : 'ok'}">Metric: ${em} vs ${om}</div> | ||
| 273 | </body></html>`); | ||
| 274 | |||
| 275 | await page.screenshot({ path: 'test-results/interop-comparison.png', fullPage: true }); | ||
| 276 | }); | ||
| 277 | }); | ||
diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs new file mode 100644 index 0000000..f4cbe01 --- /dev/null +++ b/tests/e2e/playwright.config.mjs | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | import { defineConfig } from '@playwright/test'; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | testDir: '.', | ||
| 5 | testMatch: '*.spec.mjs', | ||
| 6 | timeout: 120000, | ||
| 7 | retries: 0, | ||
| 8 | use: { | ||
| 9 | headless: true, | ||
| 10 | viewport: { width: 1280, height: 900 }, | ||
| 11 | screenshot: 'on', | ||
| 12 | video: 'retain-on-failure', | ||
| 13 | trace: 'on-first-retry', | ||
| 14 | }, | ||
| 15 | reporter: [['list'], ['html', { open: 'never' }]], | ||
| 16 | outputDir: 'test-results', | ||
| 17 | workers: 1, | ||
| 18 | }); | ||