This commit is contained in:
2025-12-01 17:27:56 +01:00
parent e7ada8aa23
commit 98a09a3f2b
41 changed files with 2341 additions and 1525 deletions

View File

@@ -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 27)', () => {
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,
);
});

View File

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

View 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,
);
});

View 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,
);
});

View 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,
);
});

View File

@@ -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',

View File

@@ -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',

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

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

View File

@@ -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,
};
}

View 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,
);
});

View File

@@ -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,
);
});

View File

@@ -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 117 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)', async () => {
const { repository, engine, useCase } = createFixtureEngine();
it(
'runs 117 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();
},
);
});

View File

@@ -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 79 cars flow (fixture-backed)', () => {
describe('Workflow steps 79 cars flow (fixture-backed, real stack)', () => {
let adapter: PlaywrightAutomationAdapter;
let server: FixtureServer;
let baseUrl: string;
@@ -16,15 +17,21 @@ describe('Workflow steps 79 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 79 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);
},
);
});

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({