wip
This commit is contained in:
@@ -1,9 +1,160 @@
|
||||
/**
|
||||
* Legacy real automation smoke suite (retired).
|
||||
*
|
||||
* Canonical full hosted-session workflow coverage now lives in
|
||||
* [companion-ui-full-workflow.e2e.test.ts](tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts).
|
||||
*
|
||||
* This file is intentionally test-empty to avoid duplicate or misleading
|
||||
* coverage while keeping the historical entrypoint discoverable.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import {
|
||||
FixtureServer,
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
|
||||
describe('Real Playwright hosted-session smoke (fixtures, steps 2–7)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 8000,
|
||||
mode: 'real',
|
||||
baseUrl,
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
expect(adapter.getPage()).not.toBeNull();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
if (server) {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectContextOpen(stepLabel: string) {
|
||||
const page = adapter.getPage();
|
||||
expect(page, `${stepLabel}: page should exist`).not.toBeNull();
|
||||
const closed = await page!.isClosed();
|
||||
expect(closed, `${stepLabel}: page should be open`).toBe(false);
|
||||
expect(adapter.isConnected(), `${stepLabel}: adapter stays connected`).toBe(true);
|
||||
}
|
||||
|
||||
async function navigateToFixtureStep(
|
||||
stepNumber: number,
|
||||
label: string,
|
||||
stepKey?: keyof typeof IRACING_SELECTORS.wizard.stepContainers,
|
||||
) {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(stepNumber));
|
||||
await page!.waitForLoadState('domcontentloaded');
|
||||
await expectContextOpen(`after navigate step ${stepNumber} (${label})`);
|
||||
|
||||
if (stepKey) {
|
||||
const selector = IRACING_SELECTORS.wizard.stepContainers[stepKey];
|
||||
const container = page!.locator(selector).first();
|
||||
const count = await container.count();
|
||||
expect(
|
||||
count,
|
||||
`${label}: expected container ${selector} to exist on fixture HTML`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
it(
|
||||
'keeps browser context open and reaches Time Limits using real adapter against fixtures',
|
||||
async () => {
|
||||
await navigateToFixtureStep(2, 'Create a Race');
|
||||
|
||||
const step2Result = await adapter.executeStep(
|
||||
StepId.create(2),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
expect(step2Result.success).toBe(true);
|
||||
await expectContextOpen('after step 2');
|
||||
|
||||
await navigateToFixtureStep(3, 'Race Information', 'raceInformation');
|
||||
|
||||
const step3Result = await adapter.executeStep(
|
||||
StepId.create(3),
|
||||
{
|
||||
sessionName: 'GridPilot Smoke Session',
|
||||
password: 'smokepw',
|
||||
description: 'Real Playwright smoke path using fixtures',
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step3Result.success).toBe(true);
|
||||
await expectContextOpen('after step 3');
|
||||
|
||||
await navigateToFixtureStep(4, 'Server Details', 'serverDetails');
|
||||
|
||||
const step4Result = await adapter.executeStep(
|
||||
StepId.create(4),
|
||||
{
|
||||
region: 'US',
|
||||
startNow: true,
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step4Result.success).toBe(true);
|
||||
await expectContextOpen('after step 4');
|
||||
|
||||
await navigateToFixtureStep(5, 'Set Admins', 'admins');
|
||||
|
||||
const step5Result = await adapter.executeStep(
|
||||
StepId.create(5),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
expect(step5Result.success).toBe(true);
|
||||
await expectContextOpen('after step 5');
|
||||
|
||||
await navigateToFixtureStep(6, 'Admins drawer', 'admins');
|
||||
|
||||
const step6Result = await adapter.executeStep(
|
||||
StepId.create(6),
|
||||
{
|
||||
adminSearch: 'Marc',
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step6Result.success).toBe(true);
|
||||
await expectContextOpen('after step 6');
|
||||
|
||||
await navigateToFixtureStep(7, 'Time Limits', 'timeLimit');
|
||||
|
||||
const step7Result = await adapter.executeStep(
|
||||
StepId.create(7),
|
||||
{
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step7Result.success).toBe(true);
|
||||
await expectContextOpen('after step 7');
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText || '').toMatch(/Cars/i);
|
||||
|
||||
const overlay = await page!.$('#gridpilot-overlay');
|
||||
expect(overlay, 'overlay should be present in real mode').not.toBeNull();
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,141 @@
|
||||
/**
|
||||
* Experimental Playwright+Electron companion UI workflow E2E (retired).
|
||||
*
|
||||
* This suite attempted to drive the Electron-based companion renderer via
|
||||
* Playwright's Electron driver, but it cannot run in this environment because
|
||||
* Electron embeds Node.js 16.17.1 while the installed Playwright version
|
||||
* requires Node.js 18 or higher.
|
||||
*
|
||||
* Companion behavior is instead covered by:
|
||||
* - Playwright-based automation E2Es and integrations against fixtures.
|
||||
* - Electron build/init/DI smoke tests.
|
||||
* - Domain and application unit/integration tests.
|
||||
*
|
||||
* This file is intentionally test-empty to avoid misleading Playwright+Electron
|
||||
* coverage while keeping the historical entrypoint discoverable.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { DIContainer } from '../../../apps/companion/main/di-container';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import type { HostedSessionConfig } from 'packages/domain/entities/HostedSessionConfig';
|
||||
import { PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation';
|
||||
|
||||
describe('Companion UI - hosted workflow via fixture-backed real stack', () => {
|
||||
let container: DIContainer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let sessionId: string;
|
||||
let originalEnv: string | undefined;
|
||||
let originalFixtureFlag: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
originalEnv = process.env.NODE_ENV;
|
||||
originalFixtureFlag = process.env.COMPANION_FIXTURE_HOSTED;
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.COMPANION_FIXTURE_HOSTED = '1';
|
||||
|
||||
DIContainer.resetInstance();
|
||||
container = DIContainer.getInstance();
|
||||
|
||||
const connection = await container.initializeBrowserConnection();
|
||||
expect(connection.success).toBe(true);
|
||||
|
||||
const browserAutomation = container.getBrowserAutomation();
|
||||
expect(browserAutomation).toBeInstanceOf(PlaywrightAutomationAdapter);
|
||||
adapter = browserAutomation as PlaywrightAutomationAdapter;
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
expect(adapter.getPage()).not.toBeNull();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await container.shutdown();
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
process.env.COMPANION_FIXTURE_HOSTED = originalFixtureFlag;
|
||||
});
|
||||
|
||||
async function waitForFinalSession(deadlineMs: number) {
|
||||
const repo = container.getSessionRepository();
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
let finalSession = null;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repo.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && (finalSession.state.isStoppedAtStep18() || finalSession.state.isCompleted())) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for hosted workflow to complete via companion DI stack');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
|
||||
it(
|
||||
'drives AutomationEngineAdapter via DI over fixtures and shows overlay progress',
|
||||
async () => {
|
||||
const startUseCase = container.getStartAutomationUseCase();
|
||||
const repo = container.getSessionRepository();
|
||||
|
||||
const config: HostedSessionConfig = {
|
||||
sessionName: 'Companion E2E - fixture hosted workflow',
|
||||
serverName: 'Companion Fixture Server',
|
||||
password: 'companion',
|
||||
adminPassword: 'admin-companion',
|
||||
maxDrivers: 20,
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
weatherType: 'dynamic',
|
||||
timeOfDay: 'afternoon',
|
||||
sessionDuration: 60,
|
||||
practiceLength: 10,
|
||||
qualifyingLength: 10,
|
||||
warmupLength: 5,
|
||||
raceLength: 30,
|
||||
startType: 'standing',
|
||||
restarts: 'single-file',
|
||||
damageModel: 'realistic',
|
||||
trackState: 'auto'
|
||||
};
|
||||
|
||||
const dto = await startUseCase.execute(config);
|
||||
expect(dto.state).toBe('PENDING');
|
||||
expect(dto.currentStep).toBe(1);
|
||||
sessionId = dto.sessionId;
|
||||
|
||||
const session = await repo.findById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.state.isPending()).toBe(true);
|
||||
|
||||
await adapter.navigateToPage('http://localhost:3456/');
|
||||
const engine = container.getAutomationEngine();
|
||||
await engine.executeStep(StepId.create(1), config);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
await page!.waitForSelector('#gridpilot-overlay', { state: 'attached', timeout: 30000 });
|
||||
const startingText = await page!.textContent('#gridpilot-action');
|
||||
expect(startingText ?? '').not.toEqual('');
|
||||
|
||||
let reachedStep7OrBeyond = false;
|
||||
|
||||
const deadlineForProgress = Date.now() + 60000;
|
||||
while (Date.now() < deadlineForProgress) {
|
||||
const updated = await repo.findById(sessionId);
|
||||
if (updated && updated.currentStep.value >= 7) {
|
||||
reachedStep7OrBeyond = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
expect(reachedStep7OrBeyond).toBe(true);
|
||||
|
||||
const overlayStepText = await page!.textContent('#gridpilot-step-text');
|
||||
const overlayBody = (overlayStepText ?? '').toLowerCase();
|
||||
expect(
|
||||
overlayBody.includes('time limits') ||
|
||||
overlayBody.includes('cars') ||
|
||||
overlayBody.includes('track options')
|
||||
).toBe(true);
|
||||
|
||||
const finalSession = await waitForFinalSession(60000);
|
||||
expect(finalSession.state.isStoppedAtStep18() || finalSession.state.isCompleted()).toBe(true);
|
||||
expect(finalSession.errorMessage).toBeUndefined();
|
||||
|
||||
const progressState = finalSession.state.value;
|
||||
expect(['STOPPED_AT_STEP_18', 'COMPLETED']).toContain(progressState);
|
||||
},
|
||||
180000
|
||||
);
|
||||
});
|
||||
146
tests/e2e/hosted-real/cars-flow.real.e2e.test.ts
Normal file
146
tests/e2e/hosted-real/cars-flow.real.e2e.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import {
|
||||
IRACING_SELECTORS,
|
||||
IRACING_TIMEOUTS,
|
||||
} from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
|
||||
const shouldRun = process.env.HOSTED_REAL_E2E === '1';
|
||||
const describeMaybe = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeMaybe('Real-site hosted session – Cars flow (members.iracing.com)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
mode: 'real',
|
||||
baseUrl: '',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const step2Result = await adapter.executeStep(StepId.create(2), {});
|
||||
expect(step2Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
await expect(
|
||||
createRaceButton.count(),
|
||||
'Create Race button should exist on Hosted Racing page',
|
||||
).resolves.toBeGreaterThan(0);
|
||||
|
||||
await createRaceButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const raceInfoContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.raceInformation)
|
||||
.first();
|
||||
await raceInfoContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await raceInfoContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const sessionConfig = {
|
||||
sessionName: 'GridPilot Real – Cars flow',
|
||||
password: 'cars-flow-secret',
|
||||
description: 'Real-site cars flow short path',
|
||||
};
|
||||
const step3Result = await adapter.executeStep(StepId.create(3), sessionConfig);
|
||||
expect(step3Result.success).toBe(true);
|
||||
|
||||
const carsSidebarLink = page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.cars)
|
||||
.first();
|
||||
await carsSidebarLink.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await carsSidebarLink.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
await carsContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'opens Add Car UI on real site and lists at least one car',
|
||||
async () => {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
await carsContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
await addCarButton.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||
|
||||
await addCarButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const addCarModal = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarModal)
|
||||
.first();
|
||||
await addCarModal.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await addCarModal.count()).toBeGreaterThan(0);
|
||||
|
||||
const carsTable = addCarModal
|
||||
.locator('table.table.table-striped tbody tr')
|
||||
.first();
|
||||
await carsTable.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
const rowCount = await addCarModal
|
||||
.locator('table.table.table-striped tbody tr')
|
||||
.count();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
108
tests/e2e/hosted-real/login-and-wizard-smoke.e2e.test.ts
Normal file
108
tests/e2e/hosted-real/login-and-wizard-smoke.e2e.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import {
|
||||
IRACING_SELECTORS,
|
||||
IRACING_TIMEOUTS,
|
||||
} from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
|
||||
const shouldRun = process.env.HOSTED_REAL_E2E === '1';
|
||||
|
||||
const describeMaybe = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeMaybe('Real-site hosted session smoke – login and wizard entry (members.iracing.com)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
mode: 'real',
|
||||
baseUrl: '',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'logs in, reaches Hosted Racing, and opens Create Race wizard',
|
||||
async () => {
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
await expect(
|
||||
createRaceButton.count(),
|
||||
'Create Race button should exist on Hosted Racing page',
|
||||
).resolves.toBeGreaterThan(0);
|
||||
|
||||
const hostedTab = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.hostedTab)
|
||||
.first();
|
||||
await hostedTab.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const step2Result = await adapter.executeStep(StepId.create(2), {});
|
||||
expect(step2Result.success).toBe(true);
|
||||
|
||||
const modalSelector = IRACING_SELECTORS.hostedRacing.createRaceModal;
|
||||
const modal = page!.locator(modalSelector).first();
|
||||
await modal.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const newRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.newRaceButton)
|
||||
.first();
|
||||
await newRaceButton.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
await newRaceButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const raceInfoContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.raceInformation)
|
||||
.first();
|
||||
await raceInfoContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const modalContent = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.modalContent)
|
||||
.first()
|
||||
.count();
|
||||
expect(
|
||||
modalContent,
|
||||
'Race creation wizard modal content should be present',
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
162
tests/e2e/hosted-real/step-03-race-information.real.e2e.test.ts
Normal file
162
tests/e2e/hosted-real/step-03-race-information.real.e2e.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import {
|
||||
IRACING_SELECTORS,
|
||||
IRACING_TIMEOUTS,
|
||||
} from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
|
||||
const shouldRun = process.env.HOSTED_REAL_E2E === '1';
|
||||
const describeMaybe = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeMaybe('Real-site hosted session – Race Information step (members.iracing.com)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
mode: 'real',
|
||||
baseUrl: '',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const step2Result = await adapter.executeStep(StepId.create(2), {});
|
||||
expect(step2Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
await expect(
|
||||
createRaceButton.count(),
|
||||
'Create Race button should exist on Hosted Racing page',
|
||||
).resolves.toBeGreaterThan(0);
|
||||
|
||||
await createRaceButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const raceInfoContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.raceInformation)
|
||||
.first();
|
||||
await raceInfoContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await raceInfoContainer.count()).toBeGreaterThan(0);
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'shows Race Information sidebar text matching fixtures and keeps text inputs writable',
|
||||
async () => {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarLink = page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.raceInformation)
|
||||
.first();
|
||||
await sidebarLink.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
const sidebarText = (await sidebarLink.innerText()).trim();
|
||||
expect(sidebarText.length).toBeGreaterThan(0);
|
||||
|
||||
let fixtureSidebarText: string | null = null;
|
||||
try {
|
||||
const fixturePath = path.join(
|
||||
process.cwd(),
|
||||
'html-dumps-optimized',
|
||||
'iracing-hosted-sessions',
|
||||
'03-race-information.json',
|
||||
);
|
||||
const raw = await fs.readFile(fixturePath, 'utf8');
|
||||
const items = JSON.parse(raw) as any[];
|
||||
const sidebarItem =
|
||||
items.find(
|
||||
(i) =>
|
||||
i.i === 'wizard-sidebar-link-set-session-information' &&
|
||||
typeof i.t === 'string',
|
||||
) ?? null;
|
||||
if (sidebarItem) {
|
||||
fixtureSidebarText = sidebarItem.t as string;
|
||||
}
|
||||
} catch {
|
||||
fixtureSidebarText = null;
|
||||
}
|
||||
|
||||
if (fixtureSidebarText) {
|
||||
const expected = fixtureSidebarText.toLowerCase();
|
||||
const actual = sidebarText.toLowerCase();
|
||||
expect(
|
||||
actual.includes('race') || actual.includes(expected.slice(0, 4)),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const config = {
|
||||
sessionName: 'GridPilot Real – Race Information',
|
||||
password: 'real-site-secret',
|
||||
description: 'Real-site Race Information writable fields check',
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(StepId.create(3), config);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const sessionNameInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.sessionName)
|
||||
.first();
|
||||
const passwordInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.password)
|
||||
.first();
|
||||
const descriptionInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.description)
|
||||
.first();
|
||||
|
||||
await sessionNameInput.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await passwordInput.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await descriptionInput.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const sessionNameValue = await sessionNameInput.inputValue();
|
||||
const passwordValue = await passwordInput.inputValue();
|
||||
const descriptionValue = await descriptionInput.inputValue();
|
||||
|
||||
expect(sessionNameValue).toBe(config.sessionName);
|
||||
expect(passwordValue).toBe(config.password);
|
||||
expect(descriptionValue).toBe(config.description);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -20,10 +20,11 @@ describe('Step 3 – race information', () => {
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarRaceInfo = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-session-information',
|
||||
);
|
||||
expect(sidebarRaceInfo).toContain('Race Information');
|
||||
const sidebarRaceInfo = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.raceInformation)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarRaceInfo).toMatch(/Race Information/i);
|
||||
|
||||
const config = {
|
||||
sessionName: 'GridPilot E2E Session',
|
||||
|
||||
@@ -20,10 +20,16 @@ describe('Step 4 – server details', () => {
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarServerDetails = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-server-details',
|
||||
);
|
||||
expect(sidebarServerDetails).toContain('Server Details');
|
||||
const sidebarServerDetails = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.serverDetails)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarServerDetails).toMatch(/Server Details/i);
|
||||
|
||||
const serverDetailsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.serverDetails)
|
||||
.first();
|
||||
expect(await serverDetailsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const config = {
|
||||
region: 'US-East-OH',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 5 – set admins', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -18,11 +19,17 @@ describe('Step 5 – set admins', () => {
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarAdmins = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-admins',
|
||||
);
|
||||
expect(sidebarAdmins).toContain('Admins');
|
||||
|
||||
const sidebarAdmins = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.admins)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarAdmins).toMatch(/Admins/i);
|
||||
|
||||
const adminsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.admins)
|
||||
.first();
|
||||
expect(await adminsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Add an Admin');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 6 – admins', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -18,8 +19,16 @@ describe('Step 6 – admins', () => {
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarAdmins = await page!.textContent('#wizard-sidebar-link-set-admins');
|
||||
expect(sidebarAdmins).toContain('Admins');
|
||||
const sidebarAdmins = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.admins)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarAdmins).toMatch(/Admins/i);
|
||||
|
||||
const adminsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.admins)
|
||||
.first();
|
||||
expect(await adminsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const result = await harness.executeStep(6, {
|
||||
adminSearch: 'Marc',
|
||||
@@ -42,6 +51,11 @@ describe('Step 6 – admins', () => {
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const adminsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.admins)
|
||||
.first();
|
||||
expect(await adminsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const header = await page!.textContent('#set-admins .card-header');
|
||||
expect(header).toContain('Set Admins');
|
||||
|
||||
|
||||
@@ -17,23 +17,23 @@ describe('Step 8 – cars', () => {
|
||||
describe('alignment', () => {
|
||||
it('executes on Cars page in mock wizard and exposes Add Car UI', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
const addCarText = await addCarButton.innerText();
|
||||
expect(addCarText.toLowerCase()).toContain('add a car');
|
||||
|
||||
const result = await harness.executeStep(8, {});
|
||||
|
||||
|
||||
const result = await harness.executeStepWithFixtureMismatch(8, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
@@ -45,7 +45,7 @@ describe('Step 8 – cars', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(8, {});
|
||||
await harness.executeStepWithFixtureMismatch(8, {});
|
||||
}).rejects.toThrow(/Step 8 FAILED validation/i);
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('Step 8 – cars', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(8, {});
|
||||
await harness.executeStepWithFixtureMismatch(8, {});
|
||||
}).rejects.toThrow(/Step 8 FAILED validation/i);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('Step 8 – cars', () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const result = await harness.executeStep(8, {});
|
||||
const result = await harness.executeStepWithFixtureMismatch(8, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('Step 9 – add car', () => {
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const result = await harness.executeStep(9, {
|
||||
const result = await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(9, {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Mazda MX-5',
|
||||
});
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
@@ -56,7 +56,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(9, {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Porsche 911',
|
||||
});
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
@@ -67,7 +67,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(9, {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Ferrari 488',
|
||||
});
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
@@ -77,7 +77,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const result = await harness.executeStep(9, {
|
||||
const result = await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Step 9 – add car', () => {
|
||||
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await harness.executeStep(9, {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'BMW M4',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -129,7 +129,7 @@ describe('Step 9 – add car', () => {
|
||||
|
||||
let validationError = false;
|
||||
try {
|
||||
await harness.executeStep(9, {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Audi R8',
|
||||
});
|
||||
} catch {
|
||||
@@ -145,7 +145,7 @@ describe('Step 9 – add car', () => {
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(9, {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'McLaren 720S',
|
||||
});
|
||||
}).rejects.toThrow();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 13 – track options', () => {
|
||||
let harness: StepHarness;
|
||||
@@ -19,10 +20,16 @@ describe('Step 13 – track options', () => {
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarTrackOptions = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-track-options',
|
||||
);
|
||||
expect(sidebarTrackOptions).toContain('Track Options');
|
||||
const sidebarTrackOptions = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.trackOptions)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarTrackOptions).toMatch(/Track Options/i);
|
||||
|
||||
const trackOptionsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.trackOptions)
|
||||
.first();
|
||||
expect(await trackOptionsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Create Starting Grid');
|
||||
|
||||
18
tests/e2e/support/AutoNavGuard.ts
Normal file
18
tests/e2e/support/AutoNavGuard.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import type { PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation';
|
||||
import type { AutomationResult } from 'packages/application/ports/AutomationResults';
|
||||
|
||||
export function assertAutoNavigationConfig(config: Record<string, unknown>): void {
|
||||
if ((config as any).__skipFixtureNavigation) {
|
||||
throw new Error('__skipFixtureNavigation is forbidden in auto-navigation suites');
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeStepWithAutoNavigationGuard(
|
||||
adapter: PlaywrightAutomationAdapter,
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
assertAutoNavigationConfig(config);
|
||||
return adapter.executeStep(StepId.create(step), config);
|
||||
}
|
||||
@@ -13,13 +13,40 @@ export interface StepHarness {
|
||||
getFixtureUrl(step: number): string;
|
||||
navigateToFixtureStep(step: number): Promise<void>;
|
||||
executeStep(step: number, config: Record<string, unknown>): Promise<AutomationResult>;
|
||||
executeStepWithAutoNavigation(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult>;
|
||||
executeStepWithFixtureMismatch(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createStepHarness(): Promise<StepHarness> {
|
||||
const server = new FixtureServer();
|
||||
const { url } = await server.start();
|
||||
async function createRealAdapter(baseUrl: string): Promise<PlaywrightAutomationAdapter> {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
const adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 8000,
|
||||
mode: 'real',
|
||||
baseUrl,
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to connect Playwright adapter');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
async function createMockAdapter(): Promise<PlaywrightAutomationAdapter> {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
const adapter = new PlaywrightAutomationAdapter(
|
||||
@@ -31,18 +58,52 @@ export async function createStepHarness(): Promise<StepHarness> {
|
||||
logger,
|
||||
);
|
||||
|
||||
await adapter.connect();
|
||||
const result = await adapter.connect();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to connect mock Playwright adapter');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export async function createStepHarness(useMock: boolean = false): Promise<StepHarness> {
|
||||
const server = new FixtureServer();
|
||||
const { url } = await server.start();
|
||||
|
||||
const adapter = useMock ? await createMockAdapter() : await createRealAdapter(url);
|
||||
|
||||
async function navigateToFixtureStep(step: number): Promise<void> {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(step));
|
||||
await adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
async function executeStepWithAutoNavigation(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
if ((config as any).__skipFixtureNavigation) {
|
||||
throw new Error(
|
||||
'__skipFixtureNavigation is not allowed in auto-navigation path',
|
||||
);
|
||||
}
|
||||
return adapter.executeStep(StepId.create(step), config);
|
||||
}
|
||||
|
||||
async function executeStepWithFixtureMismatch(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
return adapter.executeStep(StepId.create(step), {
|
||||
...config,
|
||||
__skipFixtureNavigation: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeStep(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
return adapter.executeStep(StepId.create(step), config);
|
||||
return executeStepWithFixtureMismatch(step, config);
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
@@ -57,6 +118,8 @@ export async function createStepHarness(): Promise<StepHarness> {
|
||||
getFixtureUrl: (step) => server.getFixtureUrl(step),
|
||||
navigateToFixtureStep,
|
||||
executeStep,
|
||||
executeStepWithAutoNavigation,
|
||||
executeStepWithFixtureMismatch,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
87
tests/e2e/validators/hosted-validator-guards.e2e.test.ts
Normal file
87
tests/e2e/validators/hosted-validator-guards.e2e.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
import { executeStepWithAutoNavigationGuard } from '../support/AutoNavGuard';
|
||||
|
||||
describe('Hosted validator guards (fixture-backed, real stack)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 15_000,
|
||||
baseUrl,
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
if (server) {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'runs a short hosted sequence (3 → 4 → 5) with autonav and no validator failures',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(3));
|
||||
const step3Result = await executeStepWithAutoNavigationGuard(adapter, 3, {
|
||||
sessionName: 'Validator happy-path session',
|
||||
password: 'validator',
|
||||
description: 'Validator autonav slice',
|
||||
});
|
||||
expect(step3Result.success).toBe(true);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(4));
|
||||
const step4Result = await executeStepWithAutoNavigationGuard(adapter, 4, {
|
||||
region: 'US',
|
||||
startNow: true,
|
||||
});
|
||||
expect(step4Result.success).toBe(true);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(5));
|
||||
const step5Result = await executeStepWithAutoNavigationGuard(adapter, 5, {});
|
||||
expect(step5Result.success).toBe(true);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
it(
|
||||
'fails clearly when executing a mismatched step on the wrong page (validator wiring)',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||
const stepId = StepId.create(11);
|
||||
|
||||
await expect(
|
||||
adapter.executeStep(stepId, {
|
||||
trackSearch: 'Spa',
|
||||
__skipFixtureNavigation: true,
|
||||
}),
|
||||
).rejects.toThrow(/Step 11 FAILED validation|validation error/i);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
import { executeStepWithAutoNavigationGuard } from '../support/AutoNavGuard';
|
||||
|
||||
describe('Workflow – hosted session autonav slice (fixture-backed, real stack)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 15_000,
|
||||
baseUrl,
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await adapter.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
async function expectStepOnContainer(
|
||||
expectedContainer: keyof typeof IRACING_SELECTORS.wizard.stepContainers,
|
||||
) {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
const selector = IRACING_SELECTORS.wizard.stepContainers[expectedContainer];
|
||||
const container = page!.locator(selector).first();
|
||||
await container.waitFor({ state: 'attached', timeout: 10_000 });
|
||||
expect(await container.count()).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
it(
|
||||
'navigates via autonav across representative steps (1 → 3 → 7 → 9 → 13 → 17)',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(1));
|
||||
const step1Result = await executeStepWithAutoNavigationGuard(adapter, 1, {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(3));
|
||||
const step3Result = await executeStepWithAutoNavigationGuard(adapter, 3, {
|
||||
sessionName: 'Autonav workflow session',
|
||||
password: 'autonav',
|
||||
description: 'Fixture-backed autonav slice',
|
||||
});
|
||||
expect(step3Result.success).toBe(true);
|
||||
await expectStepOnContainer('raceInformation');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||
const step7Result = await executeStepWithAutoNavigationGuard(adapter, 7, {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
expect(step7Result.success).toBe(true);
|
||||
await expectStepOnContainer('timeLimit');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||
const step9Result = await executeStepWithAutoNavigationGuard(adapter, 9, {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
expect(step9Result.success).toBe(true);
|
||||
await expectStepOnContainer('cars');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(13));
|
||||
const step13Result = await executeStepWithAutoNavigationGuard(adapter, 13, {
|
||||
trackSearch: 'Spa',
|
||||
});
|
||||
expect(step13Result.success).toBe(true);
|
||||
await expectStepOnContainer('trackOptions');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(17));
|
||||
const step17Result = await executeStepWithAutoNavigationGuard(adapter, 17, {
|
||||
trackState: 'medium',
|
||||
});
|
||||
expect(step17Result.success).toBe(true);
|
||||
await expectStepOnContainer('raceOptions');
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
FixtureServer,
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import { InMemorySessionRepository } from 'packages/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { MockAutomationEngineAdapter } from 'packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter';
|
||||
import { MockBrowserAutomationAdapter } from 'packages/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter';
|
||||
import { AutomationEngineAdapter } from 'packages/infrastructure/adapters/automation/engine/AutomationEngineAdapter';
|
||||
import { StartAutomationSessionUseCase } from 'packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
|
||||
describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
describe('Workflow – hosted session end-to-end (fixture-backed, real stack)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
@@ -19,16 +19,21 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 10_000,
|
||||
baseUrl,
|
||||
mode: 'mock',
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const connectResult = await adapter.connect();
|
||||
const connectResult = await adapter.connect(false);
|
||||
expect(connectResult.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -36,114 +41,58 @@ describe('Workflow – hosted session end-to-end (fixture-backed)', () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
function createFixtureEngine() {
|
||||
function createRealEngine() {
|
||||
const repository = new InMemorySessionRepository();
|
||||
const engine = new MockAutomationEngineAdapter(adapter, repository);
|
||||
const engine = new AutomationEngineAdapter(adapter, repository);
|
||||
const useCase = new StartAutomationSessionUseCase(engine, adapter, repository);
|
||||
return { repository, engine, useCase };
|
||||
}
|
||||
|
||||
it('runs 1–17 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)', async () => {
|
||||
const { repository, engine, useCase } = createFixtureEngine();
|
||||
it(
|
||||
'runs 1–17 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)',
|
||||
async () => {
|
||||
const { repository, engine, useCase } = createRealEngine();
|
||||
|
||||
const config: any = {
|
||||
sessionName: 'Fixture E2E – full workflow',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
const config: any = {
|
||||
sessionName: 'Fixture E2E – full workflow (real stack)',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const dto = await useCase.execute(config);
|
||||
const dto = await useCase.execute(config);
|
||||
|
||||
expect(dto.state).toBe('PENDING');
|
||||
expect(dto.currentStep).toBe(1);
|
||||
expect(dto.state).toBe('PENDING');
|
||||
expect(dto.currentStep).toBe(1);
|
||||
|
||||
await engine.executeStep(StepId.create(1), config);
|
||||
await adapter.navigateToPage(server.getFixtureUrl(1));
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
let finalSession = null;
|
||||
await engine.executeStep(StepId.create(1), config);
|
||||
|
||||
// Poll repository until automation loop completes
|
||||
// MockAutomationEngineAdapter drives the step orchestrator internally.
|
||||
// Session should end in STOPPED_AT_STEP_18 after completing automated step 17.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repository.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
const deadline = Date.now() + 60_000;
|
||||
let finalSession = null;
|
||||
|
||||
if (finalSession && finalSession.state.isStoppedAtStep18()) {
|
||||
break;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repository.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && finalSession.state.isStoppedAtStep18()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for automation workflow to complete');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for automation workflow to complete');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
expect(finalSession).not.toBeNull();
|
||||
expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
expect(finalSession!.currentStep.value).toBe(17);
|
||||
expect(finalSession!.startedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.completedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks session as FAILED on mid-flow automation error with diagnostics', async () => {
|
||||
const repository = new InMemorySessionRepository();
|
||||
const failingAdapter = new MockBrowserAutomationAdapter({
|
||||
simulateFailures: true,
|
||||
failureRate: 1.0,
|
||||
});
|
||||
await failingAdapter.connect();
|
||||
|
||||
const engine = new MockAutomationEngineAdapter(
|
||||
failingAdapter as any,
|
||||
repository,
|
||||
);
|
||||
const useCase = new StartAutomationSessionUseCase(
|
||||
engine,
|
||||
failingAdapter as any,
|
||||
repository,
|
||||
);
|
||||
|
||||
const config: any = {
|
||||
sessionName: 'Fixture E2E – failure workflow',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const dto = await useCase.execute(config);
|
||||
|
||||
expect(dto.state).toBe('PENDING');
|
||||
expect(dto.currentStep).toBe(1);
|
||||
|
||||
await engine.executeStep(StepId.create(1), config);
|
||||
|
||||
const deadline = Date.now() + 30_000;
|
||||
let finalSession = null;
|
||||
|
||||
// Poll for failure state
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repository.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && finalSession.state.isFailed()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for automation workflow to fail');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
await failingAdapter.disconnect();
|
||||
|
||||
expect(finalSession).not.toBeNull();
|
||||
expect(finalSession!.state.isFailed()).toBe(true);
|
||||
expect(finalSession!.errorMessage).toBeDefined();
|
||||
});
|
||||
expect(finalSession).not.toBeNull();
|
||||
expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
expect(finalSession!.currentStep.value).toBe(17);
|
||||
expect(finalSession!.startedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.completedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.errorMessage).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
} from 'packages/infrastructure/adapters/automation';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import { IRACING_SELECTORS } from 'packages/infrastructure/adapters/automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
|
||||
describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
describe('Workflow – steps 7–9 cars flow (fixture-backed, real stack)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let server: FixtureServer;
|
||||
let baseUrl: string;
|
||||
@@ -16,15 +17,21 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 5000,
|
||||
timeout: 8000,
|
||||
baseUrl,
|
||||
mode: 'mock',
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
await adapter.connect();
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -32,52 +39,55 @@ describe('Workflow – steps 7–9 cars flow (fixture-backed)', () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it('executes time limits, cars, and add car in sequence using fixtures and leaves JSON-backed state', async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||
const step7Result = await adapter.executeStep(StepId.create(7), {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
expect(step7Result.success).toBe(true);
|
||||
it(
|
||||
'executes time limits, cars, and add car in sequence using fixtures and leaves DOM-backed state',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||
const step7Result = await adapter.executeStep(StepId.create(7), {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
expect(step7Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const raceSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.race)
|
||||
.first();
|
||||
const raceSliderValue =
|
||||
(await raceSlider.getAttribute('data-value')) ??
|
||||
(await raceSlider.inputValue().catch(() => null));
|
||||
expect(raceSliderValue).toBe('20');
|
||||
const raceSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.race)
|
||||
.first();
|
||||
const raceSliderValue =
|
||||
(await raceSlider.getAttribute('data-value')) ??
|
||||
(await raceSlider.inputValue().catch(() => null));
|
||||
expect(raceSliderValue).toBe('20');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||
const step8Result = await adapter.executeStep(StepId.create(8), {});
|
||||
expect(step8Result.success).toBe(true);
|
||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||
const step8Result = await adapter.executeStep(StepId.create(8), {});
|
||||
expect(step8Result.success).toBe(true);
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||
const step9Result = await adapter.executeStep(StepId.create(9), {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
expect(step9Result.success).toBe(true);
|
||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||
const step9Result = await adapter.executeStep(StepId.create(9), {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
expect(step9Result.success).toBe(true);
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
});
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -23,20 +23,43 @@ describe('Browser Mode Integration - GREEN Phase', () => {
|
||||
const originalEnv = process.env;
|
||||
let adapter: PlaywrightAutomationAdapterLike | null = null;
|
||||
|
||||
let unhandledRejectionHandler: ((reason: unknown) => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
unhandledRejectionHandler = (reason: unknown) => {
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : String(reason ?? '');
|
||||
if (message.includes('cdpSession.send: Target page, context or browser has been closed')) {
|
||||
return;
|
||||
}
|
||||
throw reason;
|
||||
};
|
||||
const anyProcess = process as any;
|
||||
anyProcess.on('unhandledRejection', unhandledRejectionHandler);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
adapter = null;
|
||||
}
|
||||
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (unhandledRejectionHandler) {
|
||||
const anyProcess = process as any;
|
||||
anyProcess.removeListener('unhandledRejection', unhandledRejectionHandler);
|
||||
unhandledRejectionHandler = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('Headed Mode Launch (NODE_ENV=development, default)', () => {
|
||||
it('should launch browser with headless: false when NODE_ENV=development by default', async () => {
|
||||
// Skip: Tests must always run headless to avoid opening browsers
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OverlaySyncService } from 'packages/application/services/OverlaySyncService';
|
||||
import type { AutomationEvent } from 'packages/application/ports/IAutomationEventPublisher';
|
||||
import type {
|
||||
IAutomationLifecycleEmitter,
|
||||
LifecycleCallback,
|
||||
} from 'packages/infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||
import type {
|
||||
OverlayAction,
|
||||
ActionAck,
|
||||
} from 'packages/application/ports/IOverlaySyncPort';
|
||||
|
||||
class TestLifecycleEmitter implements IAutomationLifecycleEmitter {
|
||||
private callbacks: Set<LifecycleCallback> = new Set();
|
||||
|
||||
onLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.add(cb);
|
||||
}
|
||||
|
||||
offLifecycle(cb: LifecycleCallback): void {
|
||||
this.callbacks.delete(cb);
|
||||
}
|
||||
|
||||
async emit(event: AutomationEvent): Promise<void> {
|
||||
for (const cb of Array.from(this.callbacks)) {
|
||||
await cb(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingPublisher {
|
||||
public events: AutomationEvent[] = [];
|
||||
|
||||
async publish(event: AutomationEvent): Promise<void> {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Overlay lifecycle (integration)', () => {
|
||||
it('emits modal-opened and confirms after action-started in sane order', async () => {
|
||||
const lifecycleEmitter = new TestLifecycleEmitter();
|
||||
const publisher = new RecordingPublisher();
|
||||
const logger = console as any;
|
||||
|
||||
const service = new OverlaySyncService({
|
||||
lifecycleEmitter,
|
||||
publisher,
|
||||
logger,
|
||||
defaultTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
const action: OverlayAction = {
|
||||
id: 'hosted-session',
|
||||
label: 'Starting hosted session',
|
||||
};
|
||||
|
||||
const ackPromise: Promise<ActionAck> = service.startAction(action);
|
||||
|
||||
expect(publisher.events.length).toBe(1);
|
||||
const first = publisher.events[0];
|
||||
expect(first.type).toBe('modal-opened');
|
||||
expect(first.actionId).toBe('hosted-session');
|
||||
|
||||
await lifecycleEmitter.emit({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
});
|
||||
|
||||
await lifecycleEmitter.emit({
|
||||
type: 'action-started',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const ack = await ackPromise;
|
||||
expect(ack.id).toBe('hosted-session');
|
||||
expect(ack.status).toBe('confirmed');
|
||||
|
||||
expect(publisher.events[0].type).toBe('modal-opened');
|
||||
expect(publisher.events[0].actionId).toBe('hosted-session');
|
||||
});
|
||||
|
||||
it('emits panel-missing when cancelAction is called', async () => {
|
||||
const lifecycleEmitter = new TestLifecycleEmitter();
|
||||
const publisher = new RecordingPublisher();
|
||||
const logger = console as any;
|
||||
|
||||
const service = new OverlaySyncService({
|
||||
lifecycleEmitter,
|
||||
publisher,
|
||||
logger,
|
||||
});
|
||||
|
||||
await service.cancelAction('hosted-session-cancel');
|
||||
|
||||
expect(publisher.events.length).toBe(1);
|
||||
const ev = publisher.events[0];
|
||||
expect(ev.type).toBe('panel-missing');
|
||||
expect(ev.actionId).toBe('hosted-session-cancel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PageStateValidator } from 'packages/domain/services/PageStateValidator';
|
||||
import { StepTransitionValidator } from 'packages/domain/services/StepTransitionValidator';
|
||||
import { StepId } from 'packages/domain/value-objects/StepId';
|
||||
import { SessionState } from 'packages/domain/value-objects/SessionState';
|
||||
|
||||
describe('Validator conformance (integration)', () => {
|
||||
describe('PageStateValidator with hosted-session selectors', () => {
|
||||
it('reports missing DOM markers with descriptive message', () => {
|
||||
const validator = new PageStateValidator();
|
||||
|
||||
const actualState = (selector: string) => {
|
||||
return selector === '#set-cars';
|
||||
};
|
||||
|
||||
const result = validator.validateState(actualState, {
|
||||
expectedStep: 'track',
|
||||
requiredSelectors: ['#set-track', '#track-search'],
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const value = result.unwrap();
|
||||
expect(value.isValid).toBe(false);
|
||||
expect(value.expectedStep).toBe('track');
|
||||
expect(value.missingSelectors).toEqual(['#set-track', '#track-search']);
|
||||
expect(value.message).toBe(
|
||||
'Page state mismatch: Expected to be on "track" page but missing required elements',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports unexpected DOM markers when forbidden selectors are present', () => {
|
||||
const validator = new PageStateValidator();
|
||||
|
||||
const actualState = (selector: string) => {
|
||||
return ['#set-cars', '#set-track'].includes(selector);
|
||||
};
|
||||
|
||||
const result = validator.validateState(actualState, {
|
||||
expectedStep: 'cars',
|
||||
requiredSelectors: ['#set-cars'],
|
||||
forbiddenSelectors: ['#set-track'],
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const value = result.unwrap();
|
||||
expect(value.isValid).toBe(false);
|
||||
expect(value.expectedStep).toBe('cars');
|
||||
expect(value.unexpectedSelectors).toEqual(['#set-track']);
|
||||
expect(value.message).toBe(
|
||||
'Page state mismatch: Found unexpected elements on "cars" page',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepTransitionValidator with hosted-session steps', () => {
|
||||
it('rejects illegal forward jumps with clear error', () => {
|
||||
const currentStep = StepId.create(3);
|
||||
const nextStep = StepId.create(9);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(
|
||||
currentStep,
|
||||
nextStep,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
'Cannot skip steps - must progress sequentially',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects backward jumps with clear error', () => {
|
||||
const currentStep = StepId.create(11);
|
||||
const nextStep = StepId.create(8);
|
||||
const state = SessionState.create('IN_PROGRESS');
|
||||
|
||||
const result = StepTransitionValidator.canTransition(
|
||||
currentStep,
|
||||
nextStep,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
'Cannot move backward - steps must progress forward only',
|
||||
);
|
||||
});
|
||||
|
||||
it('provides descriptive step descriptions for hosted steps', () => {
|
||||
const step3 = StepTransitionValidator.getStepDescription(
|
||||
StepId.create(3),
|
||||
);
|
||||
const step11 = StepTransitionValidator.getStepDescription(
|
||||
StepId.create(11),
|
||||
);
|
||||
const finalStep = StepTransitionValidator.getStepDescription(
|
||||
StepId.create(17),
|
||||
);
|
||||
|
||||
expect(step3).toBe('Fill Race Information');
|
||||
expect(step11).toBe('Set Track');
|
||||
expect(finalStep).toBe(
|
||||
'Track Conditions (STOP - Manual Submit Required)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ describe('companion start automation - browser not connected at step 1', () => {
|
||||
expect(session.state.value).toBe('FAILED');
|
||||
const error = session.errorMessage as string | undefined;
|
||||
expect(error).toBeDefined();
|
||||
expect(error).toContain('Step 1 (LOGIN)');
|
||||
expect(error).toContain('Step 1 (Navigate to Hosted Racing page)');
|
||||
expect(error).toContain('Browser not connected');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MockAutomationLifecycleEmitter } from '../../../mocks/MockAutomationLifecycleEmitter';
|
||||
import { OverlaySyncService } from 'packages/application/services/OverlaySyncService';
|
||||
import type { AutomationEvent } from 'packages/application/ports/IAutomationEventPublisher';
|
||||
import type { OverlayAction } from 'packages/application/ports/IOverlaySyncPort';
|
||||
|
||||
type RendererOverlayState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'starting'; actionId: string }
|
||||
| { status: 'in-progress'; actionId: string }
|
||||
| { status: 'completed'; actionId: string }
|
||||
| { status: 'failed'; actionId: string };
|
||||
|
||||
class RecordingPublisher {
|
||||
public events: AutomationEvent[] = [];
|
||||
async publish(event: AutomationEvent): Promise<void> {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
function reduceEventsToRendererState(events: AutomationEvent[]): RendererOverlayState {
|
||||
let state: RendererOverlayState = { status: 'idle' };
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.actionId) continue;
|
||||
switch (ev.type) {
|
||||
case 'modal-opened':
|
||||
case 'panel-attached':
|
||||
state = { status: 'starting', actionId: ev.actionId };
|
||||
break;
|
||||
case 'action-started':
|
||||
state = { status: 'in-progress', actionId: ev.actionId };
|
||||
break;
|
||||
case 'action-complete':
|
||||
state = { status: 'completed', actionId: ev.actionId };
|
||||
break;
|
||||
case 'action-failed':
|
||||
case 'panel-missing':
|
||||
state = { status: 'failed', actionId: ev.actionId };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('renderer overlay lifecycle integration', () => {
|
||||
it('tracks starting → in-progress → completed lifecycle for a hosted action', async () => {
|
||||
const emitter = new MockAutomationLifecycleEmitter();
|
||||
const publisher = new RecordingPublisher();
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter as any,
|
||||
publisher: publisher as any,
|
||||
logger: console as any,
|
||||
defaultTimeoutMs: 2_000,
|
||||
});
|
||||
|
||||
const action: OverlayAction = {
|
||||
id: 'hosted-session',
|
||||
label: 'Starting hosted session',
|
||||
};
|
||||
|
||||
const ackPromise = svc.startAction(action);
|
||||
|
||||
expect(publisher.events[0]?.type).toBe('modal-opened');
|
||||
expect(publisher.events[0]?.actionId).toBe('hosted-session');
|
||||
|
||||
await emitter.emit({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
});
|
||||
|
||||
await emitter.emit({
|
||||
type: 'action-started',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const ack = await ackPromise;
|
||||
expect(ack.id).toBe('hosted-session');
|
||||
expect(ack.status).toBe('confirmed');
|
||||
|
||||
await publisher.publish({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
} as AutomationEvent);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'action-started',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
} as AutomationEvent);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'action-complete',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
} as AutomationEvent);
|
||||
|
||||
const rendererState = reduceEventsToRendererState(publisher.events);
|
||||
|
||||
expect(rendererState.status).toBe('completed');
|
||||
expect(rendererState.actionId).toBe('hosted-session');
|
||||
});
|
||||
|
||||
it('ends in failed state when panel-missing is emitted', async () => {
|
||||
const emitter = new MockAutomationLifecycleEmitter();
|
||||
const publisher = new RecordingPublisher();
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter as any,
|
||||
publisher: publisher as any,
|
||||
logger: console as any,
|
||||
defaultTimeoutMs: 200,
|
||||
});
|
||||
|
||||
const action: OverlayAction = {
|
||||
id: 'hosted-failure',
|
||||
label: 'Hosted session failing',
|
||||
};
|
||||
|
||||
void svc.startAction(action);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-failure',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
} as AutomationEvent);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'action-failed',
|
||||
actionId: 'hosted-failure',
|
||||
timestamp: Date.now(),
|
||||
payload: { reason: 'validation error' },
|
||||
} as AutomationEvent);
|
||||
|
||||
const rendererState = reduceEventsToRendererState(publisher.events);
|
||||
|
||||
expect(rendererState.status).toBe('failed');
|
||||
expect(rendererState.actionId).toBe('hosted-failure');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,23 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { PlaywrightAutomationAdapter, FixtureServer } from 'packages/infrastructure/adapters/automation';
|
||||
|
||||
describe('Playwright Adapter Smoke Tests', () => {
|
||||
let adapter: PlaywrightAutomationAdapter | undefined;
|
||||
let server: FixtureServer | undefined;
|
||||
let unhandledRejectionHandler: ((reason: unknown) => void) | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
unhandledRejectionHandler = (reason: unknown) => {
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : String(reason ?? '');
|
||||
if (message.includes('cdpSession.send: Target page, context or browser has been closed')) {
|
||||
return;
|
||||
}
|
||||
throw reason;
|
||||
};
|
||||
const anyProcess = process as any;
|
||||
anyProcess.on('unhandledRejection', unhandledRejectionHandler);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
@@ -24,6 +38,14 @@ describe('Playwright Adapter Smoke Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (unhandledRejectionHandler) {
|
||||
const anyProcess = process as any;
|
||||
anyProcess.removeListener('unhandledRejection', unhandledRejectionHandler);
|
||||
unhandledRejectionHandler = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('Adapter instantiates without errors', () => {
|
||||
expect(() => {
|
||||
adapter = new PlaywrightAutomationAdapter({
|
||||
|
||||
Reference in New Issue
Block a user