feat: add Playwright integration tests with coverage and log capture

- Added `@playwright/test` and `c8` dependencies.
- Configured Playwright in `playwright.config.js` to run the dashboard server.
- Added `test/playwright/dashboard.spec.js` to verify dashboard functionality (loading, auth, config) and capture console logs.
- Modified `src/dashboard.mjs` to support graceful shutdown via `/shutdown` endpoint (guarded by `NODE_V8_COVERAGE`) for coverage collection.
- Updated `package.json` with `test:playwright` and `test:playwright:coverage` scripts.
- Updated `.gitignore` to exclude coverage and test artifacts.
- Fixed `host` default value in `src/cli-parser.mjs`.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-22 03:22:12 +00:00
1 parent 3298830449
commit e8eec45ff7
8 files changed
+1187 -1

No files matched your search

+5
View File
@@ -26,3 +26,8 @@ reports/performance/benchmark-*.txt
test_output_full.txt
benchmark-after.txt
benchmark-baseline.txt
# Coverage
coverage/
playwright-report/
test-results/
+973
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -67,6 +67,9 @@
"validate:scheduler-failure-schema": "node --test test/scheduler-lock-failure-schema.contract.test.mjs",
"test:ci-resilience": "node --test test/lock-acquisition-resilience.integration.test.mjs",
"test:extended-main": "npm run test:integration:e2e && npm run test:ci-resilience",
"test:playwright": "playwright test",
"test:playwright:coverage": "rm -rf coverage/playwright/tmp && mkdir -p coverage/playwright/tmp && CI=true playwright test && npx c8 report --temp-directory coverage/playwright/tmp --report-dir coverage/playwright --reporter text --reporter json --all --src src",
"test:playwright:ui": "playwright test --ui",
"torch:memory:list": "node bin/torch-lock.mjs list-memories",
"torch:memory:inspect": "node bin/torch-lock.mjs inspect-memory",
"torch:remove": "node bin/torch-lock.mjs remove"
@@ -77,6 +80,8 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"c8": "^10.1.3",
"eslint": "^10.0.1",
"globals": "^17.3.0"
}
+65
View File
@@ -0,0 +1,65 @@
// @ts-check
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './test/playwright',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'list',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'node bin/torch-lock.mjs dashboard --port 3000',
port: 3000,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
env: {
NODE_V8_COVERAGE: path.resolve('coverage/playwright/tmp'),
TORCH_CONFIG_PATH: path.resolve('test/playwright/fixtures/torch-config.json'),
TORCH_DASHBOARD_AUTH: 'admin:password' // Force auth for testing
}
},
});
+1 -1
View File
@@ -17,7 +17,7 @@ export function parseArgs(argv) {
dryRun: false,
force: false,
port: DEFAULT_DASHBOARD_PORT,
host: null,
host: undefined,
logDir: 'task-logs',
ignoreLogs: false,
id: null,
+30
View File
@@ -37,6 +37,8 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const auth = await getDashboardAuth();
let gracefulShutdown;
const server = http.createServer(async (req, res) => {
try {
// Basic Auth check
@@ -71,6 +73,14 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
const url = new URL(req.url, `http://${req.headers.host}`);
let pathname = url.pathname;
// Special case: /shutdown (only if coverage is enabled)
if (pathname === '/shutdown' && process.env.NODE_V8_COVERAGE) {
res.writeHead(200, SECURITY_HEADERS);
res.end('Shutting down');
gracefulShutdown();
return;
}
// Redirect / to /dashboard/
if (pathname === '/' || pathname === '/dashboard') {
res.writeHead(302, { ...SECURITY_HEADERS, 'Location': '/dashboard/' });
@@ -230,6 +240,26 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
}
console.log(`Serving files from ${packageRoot}`);
console.log(`Using configuration from ${process.cwd()}`);
console.log(`NODE_V8_COVERAGE: ${process.env.NODE_V8_COVERAGE}`);
gracefulShutdown = () => {
console.error('Shutting down dashboard server...');
fsp.writeFile('shutdown.log', `SIGTERM received. Coverage dir: ${process.env.NODE_V8_COVERAGE}\n`).catch(() => {});
server.closeAllConnections(); // Close existing connections (including /shutdown request)
server.close(() => {
console.error('Dashboard server closed.');
process.exit(0);
});
// Force close after timeout if connections linger
setTimeout(() => {
console.error('Forcing shutdown after timeout');
process.exit(1);
}, 5000).unref();
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
resolve(server);
});
server.on('error', reject);
+78
View File
@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
// Prepare logging directory
const logDir = 'test_logs';
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
const logFile = path.join(logDir, 'playwright_console.log');
// Clear previous log
fs.writeFileSync(logFile, '');
test.use({
extraHTTPHeaders: {
// base64('admin:password') = 'YWRtaW46cGFzc3dvcmQ='
'Authorization': 'Basic YWRtaW46cGFzc3dvcmQ='
}
});
test.describe.serial('TORCH Dashboard', () => {
test.beforeEach(async ({ page }) => {
page.on('console', msg => {
const logLine = `[${msg.type()}] ${msg.text()}\n`;
fs.appendFileSync(logFile, logLine);
// Also log to stdout so it appears in the runner output
// console.log(`[Browser Console] ${msg.text()}`);
});
});
test('should load the dashboard and verify title', async ({ page }) => {
await page.goto('/');
// Check for title "TORCH Dashboard" or similar. Checking regex /TORCH/ is safer.
await expect(page).toHaveTitle(/TORCH/);
// Check for some basic element presence to ensure rendering happened
// Assuming there is a header or main content
// We can inspect dashboard/index.html to be sure
// But generic check for body visibility is a start
await expect(page.locator('body')).toBeVisible();
// Verify console log capture
await page.evaluate(() => console.log('Test log from browser'));
});
test('should fail authentication with wrong credentials', async ({ browser }) => {
const context = await browser.newContext({
extraHTTPHeaders: {
'Authorization': 'Basic YWRtaW46d3Jvbmc=' // admin:wrong
}
});
const page = await context.newPage();
const response = await page.goto('/');
expect(response.status()).toBe(401);
await context.close();
});
test('should serve public config correctly', async ({ request }) => {
const response = await request.get('/torch-config.json');
expect(response.ok()).toBeTruthy();
const config = await response.json();
expect(config.nostrLock.namespace).toBe('test-torch');
// Security check: Auth should be stripped
expect(config.dashboard.auth).toBeUndefined();
});
test('should shutdown gracefully to save coverage', async ({ request }) => {
// Attempt to shutdown. It might fail if already down (unlikely in serial).
try {
// We don't expect a response because the server dies
await request.get('/shutdown', { timeout: 1000 }).catch(() => {});
} catch (e) {
// Ignore
}
// Wait a bit for file write
await new Promise(resolve => setTimeout(resolve, 2000));
});
});
@@ -0,0 +1,30 @@
{
"nostrLock": {
"namespace": "test-torch",
"relays": ["wss://relay.damus.io"],
"relayFallbacks": [],
"ttlSeconds": 60,
"queryTimeoutMs": 5000,
"publishTimeoutMs": 5000,
"minSuccessfulRelayPublishes": 1,
"minActiveRelayPool": 1
},
"dashboard": {
"auth": "admin:password",
"defaultCadenceView": "daily",
"defaultStatusView": "active",
"relays": ["wss://relay.damus.io"],
"namespace": "test-torch",
"hashtag": "test-torch-lock"
},
"scheduler": {
"firstPromptByCadence": {
"daily": "src/prompts/daily/agent.md",
"weekly": "src/prompts/weekly/agent.md"
},
"paused": {
"daily": [],
"weekly": []
}
}
}