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

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