Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions src/browser/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const LOG_STATE: Record<LogState, { icon: string }> = {
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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion src/core/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.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;
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand All @@ -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<HTMLElement>('.tx-test-badge');
if (dot) { dot.classList.remove('running', 'pass', 'fail'); dot.classList.add(state); }
Expand Down
3 changes: 2 additions & 1 deletion src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 22 additions & 20 deletions test/specs/apiCoverage.spec.ts
Original file line number Diff line number Diff line change
@@ -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`);
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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');
});
});

Expand Down Expand Up @@ -853,34 +855,34 @@ 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);
});

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');
});
});

Expand Down
8 changes: 5 additions & 3 deletions test/specs/fixture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
11 changes: 6 additions & 5 deletions test/specs/storageState.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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`;
Expand All @@ -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);

Expand All @@ -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);
Expand Down
45 changes: 43 additions & 2 deletions test/tx.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
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;
}
Expand Down
Loading