Streaming chunked transfer with shell-first suspense and resolve scripts. Hyperscript parser/compiler/runtime expanded for conformance. WASM static assets added to OCaml host. Playwright streaming and page-level test suites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
// @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 <sel> [:text t] [:visible] [:timeout ms] [:count n])
|
|
* (click <sel> [:text t] [:nth n])
|
|
* (fill <sel> <value>)
|
|
* (assert-text <sel> <text> [:timeout ms])
|
|
* (assert-not-text <sel> <text>)
|
|
* (assert-visible <sel> [:timeout ms])
|
|
* (assert-hidden <sel> [:timeout ms])
|
|
* (assert-count <sel> <n> [:timeout ms])
|
|
* (assert-no-errors)
|
|
* (wait <ms>)
|
|
* (snapshot <sel>)
|
|
* (assert-changed <sel>)
|
|
*/
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|