From 3977a953c26d9ea56680340c5e3073bce1ffbbda Mon Sep 17 00:00:00 2001 From: developer Date: Sat, 6 Jun 2026 13:53:57 +0300 Subject: [PATCH] feat: implement autoscroll functionality for logs and enhance API server with CORS support --- CHANGELOG.md | 9 +++++++ src/browser/log.ts | 13 +++++++--- src/core/controller.ts | 18 ++++++++++++- src/core/server.ts | 3 ++- test/specs/apiCoverage.spec.ts | 42 +++++++++++++++--------------- test/specs/fixture.spec.ts | 8 +++--- test/specs/storageState.spec.ts | 11 ++++---- test/tx.config.ts | 45 +++++++++++++++++++++++++++++++-- 8 files changed, 113 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a45b9b..45018d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to `@qavajs/tx` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Fixed +- `ECONNRESET` crash when a second browser connects to the WebSocket server — each WebSocket connection now has an `error` event handler that removes the client from the active set, preventing the error from becoming an unhandled Node.js exception; `_send` is also wrapped in try/catch so messages dispatched to a concurrently-closing socket fail silently + +### Changed +- Specs-list autoscroll now always scrolls the running test item into view, regardless of prior manual scrolling — the `_userScrolled` one-shot disable flag has been removed +- Log section autoscroll is now "smart": new log entries scroll to the bottom only when the user is already within 40 px of it; scrolling up to review earlier entries pauses auto-scroll, and it resumes automatically once the user scrolls back near the bottom + ## [0.0.14] ### Added diff --git a/src/browser/log.ts b/src/browser/log.ts index 8985f2c..155f7fc 100644 --- a/src/browser/log.ts +++ b/src/browser/log.ts @@ -29,6 +29,11 @@ const LOG_STATE: Record = { let _logContainer: HTMLElement | null = null; export function setLogContainer(el: HTMLElement | null): void { _logContainer = el; } +function _scrollToBottom(container: HTMLElement): void { + const gap = container.scrollHeight - container.scrollTop - container.clientHeight; + if (gap < 40) container.scrollTop = container.scrollHeight; +} + export interface LogEntry { cmd: string; message: string; @@ -65,7 +70,7 @@ function createLogEntry(message: string, state: LogState, cmd?: string, duration entry.appendChild(durEl); } container.appendChild(entry); - container.scrollTop = container.scrollHeight; + _scrollToBottom(container); return entry; } @@ -115,14 +120,14 @@ export function attach(label: string, body: string, contentType = 'text/plain'): img.className = 'tx-attachment-img'; img.title = label; container.appendChild(img); - container.scrollTop = container.scrollHeight; + _scrollToBottom(container); } else if (contentType === 'text/html') { const btn = document.createElement('button'); btn.className = 'tx-attachment-html-btn'; btn.textContent = '⊞ View HTML'; btn.onclick = () => (window as any).openHtmlAttachment?.(body, label); container.appendChild(btn); - container.scrollTop = container.scrollHeight; + _scrollToBottom(container); } } @@ -219,7 +224,7 @@ function logGroup(message: string, cmdOrFn?: string | (() => any), fn?: () => an groupEl.appendChild(hdrEl); groupEl.appendChild(bodyEl); container.appendChild(groupEl); - container.scrollTop = container.scrollHeight; + _scrollToBottom(container); } const savedContainer = _logContainer; diff --git a/src/core/controller.ts b/src/core/controller.ts index 52eaa41..a5205d8 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -21,6 +21,20 @@ declare global { (window as any).tx = { page, expect, browser, node, request, log, attach }; +// ── Autoscroll to running test ──────────────────────────────────────────────── + +function scrollToRunningItem(item: HTMLElement) { + const container = document.querySelector('.tx-specs-scroll'); + if (!container) return; + const cr = container.getBoundingClientRect(); + const ir = item.getBoundingClientRect(); + if (ir.top < cr.top) { + container.scrollTop += ir.top - cr.top; + } else if (ir.bottom > cr.bottom) { + container.scrollTop += ir.bottom - cr.bottom; + } +} + // ── Inline test log ─────────────────────────────────────────────────────────── let _activeTestLog: HTMLElement | null = null; @@ -61,7 +75,8 @@ function appendErrorToLog(error: string) { stackEl.textContent = error; el.appendChild(stackEl); } - el.scrollTop = el.scrollHeight; + const gap = el.scrollHeight - el.scrollTop - el.clientHeight; + if (gap < 40) el.scrollTop = el.scrollHeight; } // ── Spec card helpers ───────────────────────────────────────────────────────── @@ -84,6 +99,7 @@ function setTestItemStatus(filename: string, fullName: string, state: 'running'| if (!item) return; item.classList.remove('running', 'pass', 'fail'); item.classList.add(state); + if (state === 'running') scrollToRunningItem(item); const dot = item.querySelector('.tx-test-dot'); const badge = item.querySelector('.tx-test-badge'); if (dot) { dot.classList.remove('running', 'pass', 'fail'); dot.classList.add(state); } diff --git a/src/core/server.ts b/src/core/server.ts index 7c95b45..b33dbce 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -142,6 +142,7 @@ export class TestServer { this._send(ws, { type: 'version', version: this._version }); ws.on('close', () => this._wsClients.delete(ws)); + ws.on('error', () => this._wsClients.delete(ws)); ws.on('message', (rawData: Buffer) => { let msg: BrowserMessage; @@ -360,7 +361,7 @@ export class TestServer { } private _send(ws: WebSocket, msg: object): void { - ws.send(JSON.stringify(msg)); + try { ws.send(JSON.stringify(msg)); } catch { /* ignore sends on closing sockets */ } } sendToClients(msg: object): void { diff --git a/test/specs/apiCoverage.spec.ts b/test/specs/apiCoverage.spec.ts index f34923b..cfb3346 100644 --- a/test/specs/apiCoverage.spec.ts +++ b/test/specs/apiCoverage.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from '@qavajs/tx'; +const API_BASE = 'http://localhost:3000'; + async function loadTestPage({ page, node }: any) { const dirname = await node.task('dirname'); await page.goto(`file://${dirname}/app/testPage.html`); @@ -372,15 +374,15 @@ test.describe('Route APIs', () => { test.describe('waitForRequest and waitForResponse', () => { test('waitForRequest resolves on matching request', async ({ page, request }) => { - const reqPromise = page.waitForRequest('https://httpbin.org/get', { timeout: 15000 }); - await request.fetch('https://httpbin.org/get'); + const reqPromise = page.waitForRequest(`${API_BASE}/get`, { timeout: 15000 }); + await request.fetch(`${API_BASE}/get`); const req = await reqPromise; - expect(req.url()).toContain('httpbin.org'); + expect(req.url()).toContain('localhost:3000'); }); test('waitForResponse resolves with matching response', async ({ page, request }) => { - const respPromise = page.waitForResponse('https://httpbin.org/get', { timeout: 15000 }); - await request.fetch('https://httpbin.org/get'); + const respPromise = page.waitForResponse(`${API_BASE}/get`, { timeout: 15000 }); + await request.fetch(`${API_BASE}/get`); const resp = await respPromise; expect(resp.status()).toBe(200); }); @@ -777,29 +779,29 @@ test.describe('Route – fulfill and request()', () => { test.beforeEach(async ({ page, node }) => { await loadTestPage({ page, node }); }); test('route.fulfill returns a synthetic response', async ({ page }) => { - await page.route('https://httpbin.org/get', async route => { + await page.route(`${API_BASE}/get`, async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ mocked: true }), }); }); - const result = await page.evaluate(() => - fetch('https://httpbin.org/get').then(r => r.json()) + const result = await page.evaluate((url: string) => + fetch(url).then(r => r.json()), `${API_BASE}/get` ); expect((result as any).mocked).toBe(true); }); test('route.request() exposes the intercepted request URL', async ({ page }) => { let capturedUrl = ''; - await page.route('https://httpbin.org/get', async route => { + await page.route(`${API_BASE}/get`, async route => { capturedUrl = route.request().url(); await route.abort(); }); - await page.evaluate(() => - fetch('https://httpbin.org/get').catch(() => {}) + await page.evaluate((url: string) => + fetch(url).catch(() => {}), `${API_BASE}/get` ); - expect(capturedUrl).toContain('httpbin.org/get'); + expect(capturedUrl).toContain('localhost:3000/get'); }); }); @@ -853,15 +855,15 @@ test.describe('Page events – request / response / requestfinished / requestfai test('request fires for a page-initiated fetch', async ({ page }) => { const urls: string[] = []; page.on('request', req => { urls.push(req.url()); }); - await page.evaluate(() => fetch('https://httpbin.org/get').catch(() => {})); + await page.evaluate((url: string) => fetch(url).catch(() => {}), `${API_BASE}/get`); await page.waitForTimeout(3000); - expect(urls.some(u => u.includes('httpbin.org'))).toBe(true); + expect(urls.some(u => u.includes('localhost:3000'))).toBe(true); }); test('response fires when a fetch response arrives', async ({ page }) => { const statuses: number[] = []; page.on('response', resp => { statuses.push(resp.status()); }); - await page.evaluate(() => fetch('https://httpbin.org/get').catch(() => {})); + await page.evaluate((url: string) => fetch(url).catch(() => {}), `${API_BASE}/get`); await page.waitForTimeout(3000); expect(statuses.some(s => s === 200)).toBe(true); }); @@ -869,18 +871,18 @@ test.describe('Page events – request / response / requestfinished / requestfai test('requestfinished fires after a successful request', async ({ page }) => { const finished: string[] = []; page.on('requestfinished', req => { finished.push(req.url()); }); - await page.evaluate(() => fetch('https://httpbin.org/get').catch(() => {})); + await page.evaluate((url: string) => fetch(url).catch(() => {}), `${API_BASE}/get`); await page.waitForTimeout(3000); - expect(finished.some(u => u.includes('httpbin.org'))).toBe(true); + expect(finished.some(u => u.includes('localhost:3000'))).toBe(true); }); test('requestfailed fires when route.abort() cancels a request', async ({ page }) => { let failedUrl = ''; page.on('requestfailed', req => { failedUrl = req.url(); }); - await page.route('https://httpbin.org/get', async route => { await route.abort(); }); - await page.evaluate(() => fetch('https://httpbin.org/get').catch(() => {})); + await page.route(`${API_BASE}/get`, async route => { await route.abort(); }); + await page.evaluate((url: string) => fetch(url).catch(() => {}), `${API_BASE}/get`); await page.waitForTimeout(1000); - expect(failedUrl).toContain('httpbin.org'); + expect(failedUrl).toContain('localhost:3000'); }); }); diff --git a/test/specs/fixture.spec.ts b/test/specs/fixture.spec.ts index 1041a24..164603c 100644 --- a/test/specs/fixture.spec.ts +++ b/test/specs/fixture.spec.ts @@ -50,17 +50,19 @@ myTest.describe('Fixtures', () => { }); +const API_BASE = 'http://localhost:3000'; + test.describe('API', () => { test('request fixture fetches JSON from an API', async ({ request }) => { - const resp = await request.fetch('https://httpbin.org/get'); + const resp = await request.fetch(`${API_BASE}/get`); expect(resp.status()).toBe(200); expect(resp.ok()).toBe(true); const body = await resp.json() as { url: string }; - expect(body.url).toContain('httpbin.org'); + expect(body.url).toContain('localhost:3000'); }); test('request fixture posts JSON body', async ({ request }) => { - const resp = await request.fetch('https://httpbin.org/post', { + const resp = await request.fetch(`${API_BASE}/post`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hello: 'world' }), diff --git a/test/specs/storageState.spec.ts b/test/specs/storageState.spec.ts index c882cfc..9edd59f 100644 --- a/test/specs/storageState.spec.ts +++ b/test/specs/storageState.spec.ts @@ -5,6 +5,7 @@ async function loadTestPage({ page, node }: any) { await page.goto(`file://${dirname}/app/testPage.html`); } +const API_BASE = 'http://localhost:3000'; const CUSTOM_COOKIE_VALUE = 'custom_value_123'; const CUSTOM_COOKIE_STATE = { @@ -16,13 +17,13 @@ const CUSTOM_COOKIE_STATE = { allowSpecialUseDomain: true, prefixSecurity: 'silent', cookies: [ - { key: 'tx_cookie', value: CUSTOM_COOKIE_VALUE, domain: 'httpbin.org', path: '/', hostOnly: true }, + { key: 'tx_cookie', value: CUSTOM_COOKIE_VALUE, domain: 'localhost', path: '/', hostOnly: true }, ], }, origins: [], }; -test.describe('browser.storageState – cookies (httpbin.org)', () => { +test.describe('browser.storageState – cookies (localhost)', () => { test('saves and restores cookies via file path', async ({ page, browser, node }: any) => { const dirname = await node.task('dirname'); const filePath = `${dirname}/.cookie-state-test.json`; @@ -34,7 +35,7 @@ test.describe('browser.storageState – cookies (httpbin.org)', () => { await browser.loadStorageState(filePath); - await page.goto('https://httpbin.org/cookies'); + await page.goto(`${API_BASE}/cookies`); const text = await page.evaluate(() => document.body.innerText); expect(JSON.parse(text).cookies?.tx_cookie).toBe(CUSTOM_COOKIE_VALUE); @@ -44,14 +45,14 @@ test.describe('browser.storageState – cookies (httpbin.org)', () => { test('cookie roundtrip via storageState', async ({ page, browser }: any) => { await browser.loadStorageState(CUSTOM_COOKIE_STATE); - await page.goto('https://httpbin.org/cookies'); + await page.goto(`${API_BASE}/cookies`); const text1 = await page.evaluate(() => document.body.innerText); const json1 = JSON.parse(text1); expect(json1.cookies?.tx_cookie).toBe(CUSTOM_COOKIE_VALUE); await browser.loadStorageState({ cookieJar: {}, origins: [] }); - await page.goto('https://httpbin.org/cookies'); + await page.goto(`${API_BASE}/cookies`); const text2 = await page.evaluate(() => document.body.innerText); const json2 = JSON.parse(text2); expect(Object.keys(json2.cookies ?? {}).length).toBe(0); diff --git a/test/tx.config.ts b/test/tx.config.ts index 9235c07..4e472f5 100644 --- a/test/tx.config.ts +++ b/test/tx.config.ts @@ -11,8 +11,49 @@ const MIME = { }; const appServer = http.createServer((req, res) => { - const urlPath = (req.url || '/').split('?')[0]; - const filePath = path.join(APP_DIR, urlPath === '/' ? 'index.html' : urlPath); + const pathname = (req.url || '/').split('?')[0]; + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); res.end(); return; + } + + if (req.method === 'GET' && pathname === '/get') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ url: `http://localhost:${APP_PORT}/get` })); + return; + } + if (req.method === 'POST' && pathname === '/post') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let json: unknown = null; + try { json = JSON.parse(body); } catch {} + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ json })); + }); + return; + } + if (req.method === 'GET' && pathname === '/cookies') { + const cookieHeader = req.headers.cookie || ''; + const cookies: Record = {}; + if (cookieHeader) { + for (const part of cookieHeader.split(';')) { + const eqIdx = part.indexOf('='); + if (eqIdx === -1) continue; + const k = part.slice(0, eqIdx).trim(); + const v = part.slice(eqIdx + 1); + if (k) cookies[k] = v; + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ cookies })); + return; + } + + const filePath = path.join(APP_DIR, pathname === '/' ? 'index.html' : pathname); if (!filePath.startsWith(APP_DIR + path.sep) && filePath !== APP_DIR) { res.writeHead(403); res.end('Forbidden'); return; }