wip
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user