// @ts-check /** * Generic SX page test runner. * * Discovers *.test.sx files next to components, parses defsuite/deftest * forms, and executes them as Playwright tests against a real server. * * SX test format: * * (defsuite "name" * :url "/sx/(geography.(isomorphism.streaming))" * ;; :stream true — don't wait for data-sx-ready * ;; :timeout 60000 — suite-level timeout * * (deftest "all slots resolve" * (wait-for "[data-suspense='stream-fast']" :text "Fast source" :timeout 15000) * (click "button") * (assert-text "h1" "Streaming"))) * * Primitives: * (wait-for [:text t] [:visible] [:timeout ms] [:count n]) * (click [:text t] [:nth n]) * (fill ) * (assert-text [:timeout ms]) * (assert-not-text ) * (assert-visible [:timeout ms]) * (assert-hidden [:timeout ms]) * (assert-count [:timeout ms]) * (assert-no-errors) * (wait ) * (snapshot ) * (assert-changed ) */ const { test, expect } = require('playwright/test'); const { SiteServer } = require('./site-server'); const fs = require('fs'); const path = require('path'); const PROJECT_ROOT = path.resolve(__dirname, '../..'); // --------------------------------------------------------------------------- // Discover *.test.sx files // --------------------------------------------------------------------------- function findTestFiles(dir, acc = []) { if (!fs.existsSync(dir)) return acc; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) findTestFiles(full, acc); else if (entry.name.endsWith('.test.sx')) acc.push(full); } return acc; } const SEARCH_DIRS = ['sx/sx', 'shared/sx/templates']; const testFiles = []; for (const d of SEARCH_DIRS) findTestFiles(path.join(PROJECT_ROOT, d), testFiles); // --------------------------------------------------------------------------- // Minimal SX parser — just enough for test spec structure // --------------------------------------------------------------------------- function parseSx(src) { let pos = 0; function skip() { while (pos < src.length) { if (src[pos] === ';') { while (pos < src.length && src[pos] !== '\n') pos++; } else if (/\s/.test(src[pos])) pos++; else break; } } function read() { skip(); if (pos >= src.length) return null; if (src[pos] === '(') { pos++; const list = []; while (true) { skip(); if (pos >= src.length || src[pos] === ')') { pos++; return list; } list.push(read()); } } if (src[pos] === '"') { pos++; let s = ''; while (pos < src.length && src[pos] !== '"') { if (src[pos] === '\\') { pos++; s += src[pos] || ''; } else s += src[pos]; pos++; } pos++; return { t: 's', v: s }; } let tok = ''; while (pos < src.length && !/[\s()";]/.test(src[pos])) tok += src[pos++]; if (tok === 'true') return true; if (tok === 'false') return false; if (/^-?\d+(\.\d+)?$/.test(tok)) return Number(tok); if (tok[0] === ':') return { t: 'k', v: tok.slice(1) }; return { t: 'y', v: tok }; } const forms = []; while (pos < src.length) { skip(); if (pos < src.length) { const f = read(); if (f !== null) forms.push(f); } } return forms; } function sym(node, name) { return node && node.t === 'y' && node.v === name; } function isKey(node) { return node && node.t === 'k'; } function strVal(node) { return node && node.t === 's' ? node.v : node; } // --------------------------------------------------------------------------- // Parse test file into suites // --------------------------------------------------------------------------- function parseTestFile(filePath) { const forms = parseSx(fs.readFileSync(filePath, 'utf8')); const suites = []; for (const form of forms) { if (!Array.isArray(form) || !sym(form[0], 'defsuite')) continue; const suite = { name: strVal(form[1]) || path.basename(filePath), url: '', stream: false, timeout: 30000, tests: [], file: filePath }; let i = 2; // keyword args while (i < form.length && isKey(form[i])) { const k = form[i].v; i++; if (k === 'url') { suite.url = strVal(form[i]); i++; } else if (k === 'stream') { suite.stream = form[i] !== false; i++; } else if (k === 'timeout') { suite.timeout = form[i]; i++; } else i++; } // deftest forms for (; i < form.length; i++) { if (!Array.isArray(form[i]) || !sym(form[i][0], 'deftest')) continue; const dt = form[i]; const t = { name: strVal(dt[1]) || `test-${suite.tests.length}`, steps: [] }; for (let j = 2; j < dt.length; j++) { if (Array.isArray(dt[j])) t.steps.push(parseStep(dt[j])); } suite.tests.push(t); } suites.push(suite); } return suites; } function parseStep(form) { const cmd = form[0].v; const args = []; const opts = {}; for (let i = 1; i < form.length; i++) { if (isKey(form[i])) { const k = form[i].v; i++; opts[k] = strVal(form[i]); } else args.push(strVal(form[i])); } return { cmd, args, opts }; } // --------------------------------------------------------------------------- // Step executor — maps SX primitives to Playwright calls // --------------------------------------------------------------------------- async function executeStep(page, step, state) { const { cmd, args, opts } = step; const timeout = opts.timeout ? Number(opts.timeout) : 10000; switch (cmd) { case 'wait-for': { const loc = page.locator(args[0]); if (opts.text) await expect(loc.first()).toContainText(String(opts.text), { timeout }); else if (opts.visible) await expect(loc.first()).toBeVisible({ timeout }); else if (opts.count !== undefined) await expect(loc).toHaveCount(Number(opts.count), { timeout }); else await loc.first().waitFor({ timeout }); break; } case 'click': { let loc = page.locator(args[0]); if (opts.text) loc = loc.filter({ hasText: String(opts.text) }); if (opts.nth !== undefined) await loc.nth(Number(opts.nth)).click(); else if (opts.last) await loc.last().click(); else await loc.first().click(); break; } case 'fill': { await page.locator(args[0]).first().fill(String(args[1])); break; } case 'assert-text': { await expect(page.locator(args[0]).first()).toContainText(String(args[1]), { timeout }); break; } case 'assert-not-text': { await expect(page.locator(args[0]).first()).not.toContainText(String(args[1]), { timeout: 3000 }); break; } case 'assert-visible': { await expect(page.locator(args[0]).first()).toBeVisible({ timeout }); break; } case 'assert-hidden': { await expect(page.locator(args[0]).first()).toBeHidden({ timeout }); break; } case 'assert-count': { await expect(page.locator(args[0])).toHaveCount(Number(args[1]), { timeout }); break; } case 'assert-no-errors': { // Marker — handled by test wrapper break; } case 'wait': { await page.waitForTimeout(Number(args[0])); break; } case 'snapshot': { state[args[0]] = await page.locator(args[0]).first().textContent(); break; } case 'assert-changed': { const current = await page.locator(args[0]).first().textContent(); expect(current, `Expected ${args[0]} text to change`).not.toBe(state[args[0]]); state[args[0]] = current; break; } default: throw new Error(`Unknown page test step: ${cmd}`); } } // --------------------------------------------------------------------------- // Shared server — one for all test files // --------------------------------------------------------------------------- const USE_EXTERNAL = !!process.env.SX_TEST_URL; let server; if (!USE_EXTERNAL) { test.beforeAll(async () => { server = new SiteServer(); await server.start(); }); test.afterAll(async () => { if (server) server.stop(); }); } function baseUrl() { return USE_EXTERNAL ? process.env.SX_TEST_URL : server.baseUrl; } // --------------------------------------------------------------------------- // Register discovered tests // --------------------------------------------------------------------------- if (testFiles.length === 0) { test('no page tests found', () => { console.log('No *.test.sx files found in:', SEARCH_DIRS.join(', ')); }); } for (const file of testFiles) { const suites = parseTestFile(file); const relPath = path.relative(PROJECT_ROOT, file); for (const suite of suites) { test.describe(`${suite.name} (${relPath})`, () => { test.describe.configure({ timeout: suite.timeout }); for (const t of suite.tests) { test(t.name, async ({ page }) => { // ── Diagnostics capture ── const diag = { console: [], network: [], errors: [] }; page.on('console', msg => { const entry = `[${msg.type()}] ${msg.text()}`; diag.console.push(entry); if (msg.type() === 'error') diag.errors.push(msg.text()); }); page.on('pageerror', e => { diag.errors.push('PAGE_ERROR: ' + e.message); diag.console.push('[pageerror] ' + e.message); }); page.on('response', res => { const url = res.url(); // Skip data: URLs if (!url.startsWith('data:')) { diag.network.push(`${res.status()} ${res.request().method()} ${url.replace(baseUrl(), '')}`); } }); page.on('requestfailed', req => { const url = req.url(); if (!url.startsWith('data:')) { diag.network.push(`FAILED ${req.method()} ${url.replace(baseUrl(), '')} ${req.failure()?.errorText || ''}`); } }); // ── Navigate ── const waitUntil = suite.stream ? 'commit' : 'domcontentloaded'; await page.goto(baseUrl() + suite.url, { waitUntil, timeout: 30000 }); // Wait for hydration on non-streaming pages if (!suite.stream) { try { await page.waitForSelector('html[data-sx-ready]', { timeout: 15000 }); } catch (_) { /* continue with test steps */ } } // ── Execute steps, dump diagnostics on failure ── const state = {}; try { for (const step of t.steps) { await executeStep(page, step, state); } } catch (err) { // Dump diagnostics on step failure console.log('\n═══ DIAGNOSTICS ═══'); console.log('URL:', suite.url); console.log('\n── Network (' + diag.network.length + ' requests) ──'); for (const n of diag.network) console.log(' ' + n); console.log('\n── Console (' + diag.console.length + ' entries) ──'); for (const c of diag.console) console.log(' ' + c); // DOM snapshot — first 3000 chars of body try { const bodySnap = await page.evaluate(() => { const body = document.body; if (!body) return '(no body)'; return body.innerHTML.substring(0, 3000); }); console.log('\n── DOM (first 3000 chars) ──'); console.log(bodySnap); } catch (_) {} console.log('═══════════════════\n'); throw err; } // Auto-check console errors (filter network noise) const real = diag.errors.filter(e => !e.includes('net::ERR') && !e.includes('Failed to fetch') && !e.includes('Failed to load resource') && !e.includes('404') ); if (real.length > 0) { console.log('Console errors:', real); } }); } }); } }