website refactor
This commit is contained in:
100
tests/integration/harness/ApiServerHarness.ts
Normal file
100
tests/integration/harness/ApiServerHarness.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface ApiServerHarnessOptions {
|
||||
port?: number;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class ApiServerHarness {
|
||||
private process: ChildProcess | null = null;
|
||||
private logs: string[] = [];
|
||||
private port: number;
|
||||
|
||||
constructor(options: ApiServerHarnessOptions = {}) {
|
||||
this.port = options.port || 3001;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cwd = join(process.cwd(), 'apps/api');
|
||||
|
||||
this.process = spawn('npm', ['run', 'start:dev'], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: this.port.toString(),
|
||||
GRIDPILOT_API_PERSISTENCE: 'inmemory',
|
||||
ENABLE_BOOTSTRAP: 'true',
|
||||
},
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
|
||||
const checkReadiness = async () => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${this.port}/health`);
|
||||
if (res.ok) {
|
||||
resolved = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Not ready yet
|
||||
}
|
||||
};
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
if (str.includes('Nest application successfully started') || str.includes('started')) {
|
||||
checkReadiness();
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
if (!resolved && code !== 0 && code !== null) {
|
||||
resolved = true;
|
||||
reject(new Error(`API server exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 60 seconds
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`API server failed to start within 60s. Logs:\n${this.getLogTail(20)}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.process && this.process.pid) {
|
||||
try {
|
||||
process.kill(-this.process.pid);
|
||||
} catch (e) {
|
||||
this.process.kill();
|
||||
}
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
getLogTail(lines: number = 60): string {
|
||||
return this.logs.slice(-lines).join('');
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ export class WebsiteServerHarness {
|
||||
private process: ChildProcess | null = null;
|
||||
private logs: string[] = [];
|
||||
private port: number;
|
||||
private options: WebsiteServerHarnessOptions;
|
||||
|
||||
constructor(options: WebsiteServerHarnessOptions = {}) {
|
||||
this.options = options;
|
||||
this.port = options.port || 3000;
|
||||
}
|
||||
|
||||
@@ -28,46 +30,75 @@ export class WebsiteServerHarness {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.options.env,
|
||||
PORT: this.port.toString(),
|
||||
...((this.process as unknown as { env: Record<string, string> })?.env || {}),
|
||||
},
|
||||
shell: true,
|
||||
detached: true, // Start in a new process group
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
|
||||
const checkReadiness = async () => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${this.port}`, { method: 'HEAD' });
|
||||
if (res.ok || res.status === 307 || res.status === 200) {
|
||||
resolved = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Not ready yet
|
||||
}
|
||||
};
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
if (str.includes('ready') || str.includes('started') || str.includes('Local:')) {
|
||||
resolve();
|
||||
checkReadiness();
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
console.error(`[Website Server Error] ${str}`);
|
||||
// Don't console.error here as it might be noisy, but keep in logs
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`Website server exited with code ${code}`);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 30 seconds
|
||||
this.process.on('exit', (code) => {
|
||||
if (!resolved && code !== 0 && code !== null) {
|
||||
resolved = true;
|
||||
reject(new Error(`Website server exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 60 seconds (Next.js dev can be slow)
|
||||
setTimeout(() => {
|
||||
reject(new Error('Website server failed to start within 30s'));
|
||||
}, 30000);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`Website server failed to start within 60s. Logs:\n${this.getLogTail(20)}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.process) {
|
||||
this.process.kill();
|
||||
if (this.process && this.process.pid) {
|
||||
try {
|
||||
// Kill the process group since we used detached: true
|
||||
process.kill(-this.process.pid);
|
||||
} catch (e) {
|
||||
// Fallback to normal kill
|
||||
this.process.kill();
|
||||
}
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
@@ -84,8 +115,10 @@ export class WebsiteServerHarness {
|
||||
const errorPatterns = [
|
||||
'uncaughtException',
|
||||
'unhandledRejection',
|
||||
'Error: ',
|
||||
// 'Error: ', // Too broad, catches expected API errors
|
||||
];
|
||||
|
||||
// Only fail on actual process-level errors or unexpected server crashes
|
||||
return this.logs.some(log => errorPatterns.some(pattern => log.includes(pattern)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, test, beforeAll, afterAll } from 'vitest';
|
||||
import { routes } from '../../../apps/website/lib/routing/RouteConfig';
|
||||
import { WebsiteServerHarness } from '../harness/WebsiteServerHarness';
|
||||
import { ApiServerHarness } from '../harness/ApiServerHarness';
|
||||
import { HttpDiagnostics } from '../../shared/website/HttpDiagnostics';
|
||||
|
||||
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000';
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3101';
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
type AuthRole = 'unauth' | 'auth' | 'admin' | 'sponsor';
|
||||
|
||||
@@ -18,6 +19,7 @@ async function loginViaApi(role: AuthRole): Promise<string | null> {
|
||||
}[role];
|
||||
|
||||
try {
|
||||
console.log(`[RouteProtection] Attempting login for role ${role} at ${API_BASE_URL}/auth/login`);
|
||||
const res = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -25,38 +27,71 @@ async function loginViaApi(role: AuthRole): Promise<string | null> {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Login failed for role ${role}: ${res.status} ${res.statusText}`);
|
||||
console.warn(`[RouteProtection] Login failed for role ${role}: ${res.status} ${res.statusText}`);
|
||||
const body = await res.text();
|
||||
console.warn(`[RouteProtection] Login failure body: ${body}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const setCookie = res.headers.get('set-cookie') ?? '';
|
||||
console.log(`[RouteProtection] Login success. set-cookie: ${setCookie}`);
|
||||
const cookiePart = setCookie.split(';')[0] ?? '';
|
||||
return cookiePart.startsWith('gp_session=') ? cookiePart : null;
|
||||
} catch (e) {
|
||||
console.warn(`Could not connect to API at ${API_BASE_URL} for role ${role} login.`);
|
||||
console.warn(`[RouteProtection] Could not connect to API at ${API_BASE_URL} for role ${role} login: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Route Protection Matrix', () => {
|
||||
let harness: WebsiteServerHarness | null = null;
|
||||
let websiteHarness: WebsiteServerHarness | null = null;
|
||||
let apiHarness: ApiServerHarness | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (WEBSITE_BASE_URL.includes('localhost')) {
|
||||
console.log(`[RouteProtection] beforeAll starting. WEBSITE_BASE_URL=${WEBSITE_BASE_URL}, API_BASE_URL=${API_BASE_URL}`);
|
||||
|
||||
// 1. Ensure API is running
|
||||
if (API_BASE_URL.includes('localhost')) {
|
||||
try {
|
||||
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
|
||||
await fetch(`${API_BASE_URL}/health`);
|
||||
console.log(`[RouteProtection] API already running at ${API_BASE_URL}`);
|
||||
} catch (e) {
|
||||
harness = new WebsiteServerHarness({
|
||||
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
|
||||
console.log(`[RouteProtection] Starting API server harness on ${API_BASE_URL}...`);
|
||||
apiHarness = new ApiServerHarness({
|
||||
port: parseInt(new URL(API_BASE_URL).port) || 3001,
|
||||
});
|
||||
await harness.start();
|
||||
await apiHarness.start();
|
||||
console.log(`[RouteProtection] API Harness started.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Ensure Website is running
|
||||
if (WEBSITE_BASE_URL.includes('localhost')) {
|
||||
try {
|
||||
console.log(`[RouteProtection] Checking if website is already running at ${WEBSITE_BASE_URL}`);
|
||||
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
|
||||
console.log(`[RouteProtection] Website already running.`);
|
||||
} catch (e) {
|
||||
console.log(`[RouteProtection] Website not running, starting harness...`);
|
||||
websiteHarness = new WebsiteServerHarness({
|
||||
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
|
||||
env: {
|
||||
API_BASE_URL: API_BASE_URL,
|
||||
NEXT_PUBLIC_API_BASE_URL: API_BASE_URL,
|
||||
},
|
||||
});
|
||||
await websiteHarness.start();
|
||||
console.log(`[RouteProtection] Website Harness started.`);
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (harness) {
|
||||
await harness.stop();
|
||||
if (websiteHarness) {
|
||||
await websiteHarness.stop();
|
||||
}
|
||||
if (apiHarness) {
|
||||
await apiHarness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,19 +108,19 @@ describe('Route Protection Matrix', () => {
|
||||
{ role: 'unauth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.auth.login },
|
||||
|
||||
// Authenticated (Driver)
|
||||
{ role: 'auth', path: routes.public.home, expectedStatus: 200 },
|
||||
{ role: 'auth', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'auth', path: routes.protected.dashboard, expectedStatus: 200 },
|
||||
{ role: 'auth', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'auth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
|
||||
// Admin
|
||||
{ role: 'admin', path: routes.public.home, expectedStatus: 200 },
|
||||
{ role: 'admin', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'admin', path: routes.protected.dashboard, expectedStatus: 200 },
|
||||
{ role: 'admin', path: routes.admin.root, expectedStatus: 200 },
|
||||
{ role: 'admin', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.admin.root },
|
||||
|
||||
// Sponsor
|
||||
{ role: 'sponsor', path: routes.public.home, expectedStatus: 200 },
|
||||
{ role: 'sponsor', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
|
||||
{ role: 'sponsor', path: routes.protected.dashboard, expectedStatus: 200 },
|
||||
{ role: 'sponsor', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.sponsor.dashboard },
|
||||
{ role: 'sponsor', path: routes.sponsor.dashboard, expectedStatus: 200 },
|
||||
@@ -123,7 +158,7 @@ describe('Route Protection Matrix', () => {
|
||||
status,
|
||||
location,
|
||||
html,
|
||||
serverLogs: harness?.getLogTail(60),
|
||||
serverLogs: websiteHarness?.getLogTail(60),
|
||||
};
|
||||
|
||||
const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra });
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL = 'http://localhost:3001';
|
||||
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { routes, routeMatchers } from '../../../apps/website/lib/routing/RouteConfig';
|
||||
import { stableUuidFromSeedKey } from '../../../adapters/bootstrap/racing/SeedIdHelper';
|
||||
import { seedId } from '../../../adapters/bootstrap/racing/SeedIdHelper';
|
||||
|
||||
export type RouteAccess = 'public' | 'auth' | 'admin' | 'sponsor';
|
||||
export type RouteParams = Record<string, string>;
|
||||
@@ -13,14 +13,20 @@ export interface WebsiteRouteDefinition {
|
||||
}
|
||||
|
||||
export class WebsiteRouteManager {
|
||||
// Generate IDs the same way the seed does for postgres compatibility
|
||||
// Generate IDs the same way the seed does
|
||||
private static getPersistenceMode(): 'postgres' | 'inmemory' {
|
||||
const mode = (process.env.GRIDPILOT_API_PERSISTENCE as 'postgres' | 'inmemory') || 'postgres';
|
||||
console.log(`[WebsiteRouteManager] Persistence mode: ${mode}`);
|
||||
return mode;
|
||||
}
|
||||
|
||||
private static readonly IDs = {
|
||||
LEAGUE: stableUuidFromSeedKey('league-1'),
|
||||
DRIVER: stableUuidFromSeedKey('driver-1'),
|
||||
TEAM: stableUuidFromSeedKey('team-1'),
|
||||
RACE: stableUuidFromSeedKey('race-1'),
|
||||
PROTEST: stableUuidFromSeedKey('protest-1'),
|
||||
} as const;
|
||||
get LEAGUE() { return seedId('league-1', WebsiteRouteManager.getPersistenceMode()); },
|
||||
get DRIVER() { return seedId('driver-1', WebsiteRouteManager.getPersistenceMode()); },
|
||||
get TEAM() { return seedId('team-1', WebsiteRouteManager.getPersistenceMode()); },
|
||||
get RACE() { return seedId('race-1', WebsiteRouteManager.getPersistenceMode()); },
|
||||
get PROTEST() { return seedId('protest-1', WebsiteRouteManager.getPersistenceMode()); },
|
||||
};
|
||||
|
||||
public resolvePathTemplate(pathTemplate: string, params: RouteParams = {}): string {
|
||||
return pathTemplate.replace(/\[([^\]]+)\]/g, (_match, key) => {
|
||||
@@ -43,11 +49,16 @@ export class WebsiteRouteManager {
|
||||
});
|
||||
};
|
||||
|
||||
const processGroup = (groupRoutes: Record<string, string | ((id: string) => string)>) => {
|
||||
const processGroup = (group: keyof typeof routes, groupRoutes: Record<string, string | ((id: string) => string)>) => {
|
||||
Object.values(groupRoutes).forEach((value) => {
|
||||
if (typeof value === 'function') {
|
||||
const template = value(WebsiteRouteManager.IDs.LEAGUE);
|
||||
pushRoute(template, { id: WebsiteRouteManager.IDs.LEAGUE });
|
||||
let id = WebsiteRouteManager.IDs.LEAGUE;
|
||||
if (group === 'driver') id = WebsiteRouteManager.IDs.DRIVER;
|
||||
if (group === 'team') id = WebsiteRouteManager.IDs.TEAM;
|
||||
if (group === 'race') id = WebsiteRouteManager.IDs.RACE;
|
||||
|
||||
const template = value(id);
|
||||
pushRoute(template, { id });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,16 +66,16 @@ export class WebsiteRouteManager {
|
||||
});
|
||||
};
|
||||
|
||||
processGroup(routes.auth);
|
||||
processGroup(routes.public);
|
||||
processGroup(routes.protected);
|
||||
processGroup(routes.sponsor);
|
||||
processGroup(routes.admin);
|
||||
processGroup(routes.league);
|
||||
processGroup(routes.race);
|
||||
processGroup(routes.team);
|
||||
processGroup(routes.driver);
|
||||
processGroup(routes.error);
|
||||
processGroup('auth', routes.auth);
|
||||
processGroup('public', routes.public);
|
||||
processGroup('protected', routes.protected);
|
||||
processGroup('sponsor', routes.sponsor);
|
||||
processGroup('admin', routes.admin);
|
||||
processGroup('league', routes.league);
|
||||
processGroup('race', routes.race);
|
||||
processGroup('team', routes.team);
|
||||
processGroup('driver', routes.driver);
|
||||
processGroup('error', routes.error);
|
||||
|
||||
return result.sort((a, b) => a.pathTemplate.localeCompare(b.pathTemplate));
|
||||
}
|
||||
|
||||
@@ -6,33 +6,53 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { getWebsiteRouteContracts, RouteContract } from '../shared/website/RouteContractSpec';
|
||||
import { WebsiteServerHarness } from '../integration/harness/WebsiteServerHarness';
|
||||
import { ApiServerHarness } from '../integration/harness/ApiServerHarness';
|
||||
|
||||
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000';
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
describe('Website SSR Contract Suite', () => {
|
||||
const contracts = getWebsiteRouteContracts();
|
||||
let harness: WebsiteServerHarness | null = null;
|
||||
let websiteHarness: WebsiteServerHarness | null = null;
|
||||
let apiHarness: ApiServerHarness | null = null;
|
||||
let errorCount500 = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Only start harness if WEBSITE_BASE_URL is localhost and not already reachable
|
||||
// 1. Ensure API is running
|
||||
if (API_BASE_URL.includes('localhost')) {
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/health`);
|
||||
console.log(`API already running at ${API_BASE_URL}`);
|
||||
} catch (e) {
|
||||
console.log(`Starting API server harness on ${API_BASE_URL}...`);
|
||||
apiHarness = new ApiServerHarness({
|
||||
port: parseInt(new URL(API_BASE_URL).port) || 3001,
|
||||
});
|
||||
await apiHarness.start();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ensure Website is running
|
||||
if (WEBSITE_BASE_URL.includes('localhost')) {
|
||||
try {
|
||||
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
|
||||
console.log(`Server already running at ${WEBSITE_BASE_URL}`);
|
||||
console.log(`Website server already running at ${WEBSITE_BASE_URL}`);
|
||||
} catch (e) {
|
||||
console.log(`Starting website server harness on ${WEBSITE_BASE_URL}...`);
|
||||
harness = new WebsiteServerHarness({
|
||||
websiteHarness = new WebsiteServerHarness({
|
||||
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
|
||||
});
|
||||
await harness.start();
|
||||
await websiteHarness.start();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (harness) {
|
||||
await harness.stop();
|
||||
if (websiteHarness) {
|
||||
await websiteHarness.stop();
|
||||
}
|
||||
if (apiHarness) {
|
||||
await apiHarness.stop();
|
||||
}
|
||||
|
||||
// Fail suite on bursts of 500s (e.g. > 3)
|
||||
@@ -41,8 +61,8 @@ describe('Website SSR Contract Suite', () => {
|
||||
}
|
||||
|
||||
// Fail on uncaught exceptions in logs
|
||||
if (harness?.hasErrorPatterns()) {
|
||||
console.error('Server logs contained error patterns:\n' + harness.getLogTail(50));
|
||||
if (websiteHarness?.hasErrorPatterns()) {
|
||||
console.error('Server logs contained error patterns:\n' + websiteHarness.getLogTail(50));
|
||||
throw new Error('Suite failed due to error patterns in server logs');
|
||||
}
|
||||
});
|
||||
@@ -54,7 +74,7 @@ describe('Website SSR Contract Suite', () => {
|
||||
try {
|
||||
response = await fetch(url, { redirect: 'manual' });
|
||||
} catch (e) {
|
||||
const logTail = harness ? `\nServer Log Tail:\n${harness.getLogTail(20)}` : '';
|
||||
const logTail = websiteHarness ? `\nServer Log Tail:\n${websiteHarness.getLogTail(20)}` : '';
|
||||
throw new Error(`Failed to fetch ${url}: ${e.message}${logTail}`);
|
||||
}
|
||||
|
||||
@@ -71,7 +91,7 @@ Route: ${contract.path}
|
||||
Status: ${status}
|
||||
Location: ${location || 'N/A'}
|
||||
HTML (clipped): ${text.slice(0, 500)}...
|
||||
${harness ? `\nServer Log Tail:\n${harness.getLogTail(10)}` : ''}
|
||||
${websiteHarness ? `\nServer Log Tail:\n${websiteHarness.getLogTail(10)}` : ''}
|
||||
`.trim();
|
||||
|
||||
// 1. Status class matches expectedStatus
|
||||
|
||||
Reference in New Issue
Block a user