wip
This commit is contained in:
@@ -1,79 +1,69 @@
|
|||||||
# 🧭 Orchestrator Override — Expert Team Coordination Layer
|
# 🧭 Orchestrator Mode
|
||||||
|
|
||||||
## Team Personality Layer
|
## Identity
|
||||||
All experts behave as a real elite engineering team:
|
You are **Robert C. Martin**.
|
||||||
|
You assign objectives and coordinate the expert team.
|
||||||
|
|
||||||
|
## Expert Personas
|
||||||
|
- **Grady Booch** — architecture
|
||||||
|
- **Douglas Hofstadter** — meaning, ambiguity
|
||||||
|
- **John Carmack** — debugging, failures
|
||||||
|
- **Ken Thompson** — minimal TDD implementation
|
||||||
|
- **Dieter Rams** — design clarity
|
||||||
|
- **Margaret Hamilton** — quality & safety
|
||||||
|
|
||||||
|
Experts speak:
|
||||||
- extremely concise
|
- extremely concise
|
||||||
- radically honest
|
- radically honest
|
||||||
- focused on the whole system, not just their part
|
- in their own personality
|
||||||
- minimal, purposeful dialogue when needed
|
- only about their domain
|
||||||
- each speaks in their real-world persona’s voice:
|
- never explaining implementation steps
|
||||||
- **Booch** (architecture clarity)
|
|
||||||
- **Hofstadter** (meaning, ambiguity resolution)
|
|
||||||
- **Carmack** (precision, system correctness)
|
|
||||||
- **Thompson** (minimal code correctness)
|
|
||||||
- **Rams** (design clarity)
|
|
||||||
- **Hamilton** (quality, safety)
|
|
||||||
- No expert tells another *how* to do their job.
|
|
||||||
- Experts correct each other briefly when something is structurally wrong.
|
|
||||||
|
|
||||||
Team dialogue must:
|
## Team Micro-Dialogue
|
||||||
- stay extremely short (1–2 lines per expert if needed)
|
When a mode receives a task, it may briefly include a **micro-discussion**:
|
||||||
- always move toward clarity
|
- only relevant experts speak
|
||||||
- never repeat information
|
- max 1 short line each
|
||||||
- never produce fluff
|
- no repetition
|
||||||
|
- no fluff
|
||||||
|
- only insights, risks, corrections
|
||||||
|
|
||||||
## Orchestrator Behavior
|
Then the active mode proceeds with its tool call.
|
||||||
You are **Robert C. Martin**.
|
|
||||||
Your job is to coordinate experts with:
|
|
||||||
- one cohesive objective at a time
|
|
||||||
- minimal essential context
|
|
||||||
- no methods or steps
|
|
||||||
- no technical explanation
|
|
||||||
- always the correct expert chosen by name
|
|
||||||
|
|
||||||
## “move on” Command
|
## Orchestrator Mission
|
||||||
When the user writes **“move on”** (case-insensitive):
|
You produce **one clear objective** per step:
|
||||||
|
- one purpose
|
||||||
|
- one domain area
|
||||||
|
- one reasoning path
|
||||||
|
- solvable by one expert alone
|
||||||
|
|
||||||
- continue immediately with the next TODO
|
Each objective includes:
|
||||||
- if TODO list is empty, create the next logical task
|
- what must happen
|
||||||
- assign tasks autonomously using the required Roo tools
|
- minimal context
|
||||||
- ALWAYS continue responding normally to the user
|
- the expert’s name
|
||||||
- NEVER ignore or pause user messages
|
|
||||||
|
|
||||||
“move on” simply means:
|
Never include:
|
||||||
**continue executing TODOs autonomously and delegate the next task.**
|
- how
|
||||||
|
- steps
|
||||||
|
- methods
|
||||||
|
- long explanations
|
||||||
|
|
||||||
## Objective Format
|
## “move on”
|
||||||
Each Orchestrator-issued task must:
|
When the user writes **“move on”**:
|
||||||
- be single-purpose
|
- continue processing TODOs
|
||||||
- have enough context to avoid guessing
|
- if TODOs exist → assign the next one
|
||||||
- never include method, technique, or how-to
|
- if TODOs are empty → create the next logical objective
|
||||||
- fit into the tool instructions required by Roo (especially new_task)
|
- always answer the user normally
|
||||||
|
|
||||||
## Expert Assignment Guidance
|
## Delegation Rules
|
||||||
Choose experts strictly by domain:
|
- one objective at a time
|
||||||
- **Hofstadter** → remove ambiguity
|
- no mixed goals
|
||||||
- **Carmack** → find root cause failures
|
- minimal wording
|
||||||
- **Booch** → shape architecture
|
- always specify the expert by name
|
||||||
- **Thompson** → tests + code
|
- trust the expert to know how to execute
|
||||||
- **Rams** → design clarity
|
|
||||||
- **Hamilton** → quality and safety checks
|
|
||||||
|
|
||||||
The orchestrator does **not** tell them how.
|
## Completion
|
||||||
Only what needs to be accomplished.
|
After an expert completes their task:
|
||||||
|
- update TODOs
|
||||||
## Summary Output (attempt_completion for orchestration)
|
- choose the next objective
|
||||||
Orchestrator summaries must:
|
- assign it
|
||||||
- be concise
|
- repeat until the user stops you
|
||||||
- contain stage, next expert, context, todo
|
|
||||||
- never produce logs or narrative
|
|
||||||
- prepare the next step clearly
|
|
||||||
|
|
||||||
## Team Integrity
|
|
||||||
The team must:
|
|
||||||
- look at the bigger picture
|
|
||||||
- correct each other gently but directly
|
|
||||||
- avoid tunnel vision
|
|
||||||
- stay coherent and aligned
|
|
||||||
- preserve Clean Architecture, TDD, BDD principles
|
|
||||||
- keep output minimal but meaningful
|
|
||||||
@@ -174,19 +174,21 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
|
|
||||||
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
||||||
if (checkAuthUseCase) {
|
if (checkAuthUseCase) {
|
||||||
const authResult = await checkAuthUseCase.execute();
|
const authResult = await checkAuthUseCase.execute({
|
||||||
|
verifyPageContent: true,
|
||||||
|
});
|
||||||
if (authResult.isOk()) {
|
if (authResult.isOk()) {
|
||||||
const authState = authResult.unwrap();
|
const authState = authResult.unwrap();
|
||||||
if (authState !== AuthenticationState.AUTHENTICATED) {
|
if (authState !== AuthenticationState.AUTHENTICATED) {
|
||||||
logger.warn('Not authenticated - automation cannot proceed', { authState });
|
logger.warn('Not authenticated or session expired - automation cannot proceed', { authState });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Not authenticated. Please login first.',
|
error: 'Not authenticated or session expired. Please login first.',
|
||||||
authRequired: true,
|
authRequired: true,
|
||||||
authState,
|
authState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
logger.info('Authentication verified');
|
logger.info('Authentication verified (cookies and page state)');
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Auth check failed, proceeding anyway', { error: authResult.unwrapErr().message });
|
logger.warn('Auth check failed, proceeding anyway', { error: authResult.unwrapErr().message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,10 +142,19 @@ export function App() {
|
|||||||
|
|
||||||
if (result.success && result.sessionId) {
|
if (result.success && result.sessionId) {
|
||||||
setSessionId(result.sessionId);
|
setSessionId(result.sessionId);
|
||||||
} else {
|
return;
|
||||||
setIsRunning(false);
|
|
||||||
alert(`Failed to start automation: ${result.error}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsRunning(false);
|
||||||
|
|
||||||
|
if ((result as any).authRequired) {
|
||||||
|
const nextAuthState = (result as any).authState as AuthState | undefined;
|
||||||
|
setAuthState(nextAuthState ?? 'EXPIRED');
|
||||||
|
setAuthError(result.error ?? 'Authentication required before starting automation.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Failed to start automation: ${result.error}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStopAutomation = async () => {
|
const handleStopAutomation = async () => {
|
||||||
|
|||||||
@@ -164,9 +164,12 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
|
|||||||
|
|
||||||
async initiateLogin(): Promise<Result<void>> {
|
async initiateLogin(): Promise<Result<void>> {
|
||||||
try {
|
try {
|
||||||
this.log('info', 'Opening login in Playwright browser');
|
const forceHeaded = true;
|
||||||
|
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
|
||||||
|
forceHeaded,
|
||||||
|
});
|
||||||
|
|
||||||
const connectResult = await this.browserSession.connect();
|
const connectResult = await this.browserSession.connect(forceHeaded);
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
|
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,9 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
|
|||||||
timeout: this.navigationTimeoutMs,
|
timeout: this.navigationTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.log('info', 'Browser opened to login page, waiting for login...');
|
this.log('info', forceHeaded
|
||||||
|
? 'Browser opened to login page in headed mode, waiting for login...'
|
||||||
|
: 'Browser opened to login page, waiting for login...');
|
||||||
this.authState = AuthenticationState.UNKNOWN;
|
this.authState = AuthenticationState.UNKNOWN;
|
||||||
|
|
||||||
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
|
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
|
||||||
@@ -219,7 +224,6 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
|
|||||||
try {
|
try {
|
||||||
await this.browserSession.disconnect();
|
await this.browserSession.disconnect();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.err(error instanceof Error ? error : new Error(message));
|
return Result.err(error instanceof Error ? error : new Error(message));
|
||||||
@@ -370,9 +374,9 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
|
|||||||
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
|
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
|
||||||
|
|
||||||
const pageAuthenticated =
|
const pageAuthenticated =
|
||||||
(isOnAuthenticatedPath && !isOnLoginPath && cookiesValid) ||
|
!hasLoginUI &&
|
||||||
hasAuthUI ||
|
!isOnLoginPath &&
|
||||||
(!hasLoginUI && !isOnLoginPath);
|
((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
|
||||||
|
|
||||||
this.log('debug', 'Page authentication check', {
|
this.log('debug', 'Page authentication check', {
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -90,8 +90,23 @@ export class PlaywrightBrowserSession {
|
|||||||
|
|
||||||
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
|
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
|
||||||
if (this.connected && this.page) {
|
if (this.connected && this.page) {
|
||||||
this.log('debug', 'Already connected, reusing existing connection');
|
const shouldReuse =
|
||||||
return { success: true };
|
!forceHeaded ||
|
||||||
|
this.actualBrowserMode === 'headed';
|
||||||
|
|
||||||
|
if (shouldReuse) {
|
||||||
|
this.log('debug', 'Already connected, reusing existing connection', {
|
||||||
|
browserMode: this.actualBrowserMode,
|
||||||
|
forcedHeaded: forceHeaded,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('info', 'Existing browser connection is headless, reopening in headed mode for login', {
|
||||||
|
browserMode: this.actualBrowserMode,
|
||||||
|
forcedHeaded: forceHeaded,
|
||||||
|
});
|
||||||
|
await this.closeBrowserContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isConnecting) {
|
if (this.isConnecting) {
|
||||||
|
|||||||
@@ -105,4 +105,55 @@ describeMaybe('Real-site hosted session smoke – login and wizard entry (member
|
|||||||
},
|
},
|
||||||
300_000,
|
300_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'detects login guard and does not attempt Create a Race when not authenticated',
|
||||||
|
async () => {
|
||||||
|
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||||
|
expect(step1Result.success).toBe(true);
|
||||||
|
|
||||||
|
const page = adapter.getPage();
|
||||||
|
expect(page).not.toBeNull();
|
||||||
|
|
||||||
|
const currentUrl = page!.url();
|
||||||
|
expect(currentUrl).not.toEqual('about:blank');
|
||||||
|
expect(currentUrl.toLowerCase()).toContain('iracing');
|
||||||
|
expect(currentUrl.toLowerCase()).toSatisfy((u: string) =>
|
||||||
|
u.includes('oauth.iracing.com') ||
|
||||||
|
u.includes('members.iracing.com') ||
|
||||||
|
u.includes('/login'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = page!
|
||||||
|
.locator(IRACING_SELECTORS.login.emailInput)
|
||||||
|
.first();
|
||||||
|
const passwordInput = page!
|
||||||
|
.locator(IRACING_SELECTORS.login.passwordInput)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const hasEmail = (await emailInput.count()) > 0;
|
||||||
|
const hasPassword = (await passwordInput.count()) > 0;
|
||||||
|
|
||||||
|
if (!hasEmail && !hasPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailInput.waitFor({
|
||||||
|
state: 'visible',
|
||||||
|
timeout: IRACING_TIMEOUTS.elementWait,
|
||||||
|
});
|
||||||
|
await passwordInput.waitFor({
|
||||||
|
state: 'visible',
|
||||||
|
timeout: IRACING_TIMEOUTS.elementWait,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRaceButton = page!
|
||||||
|
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||||
|
.first();
|
||||||
|
const createRaceCount = await createRaceButton.count();
|
||||||
|
|
||||||
|
expect(createRaceCount).toBe(0);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import type { Page, BrowserContext } from 'playwright';
|
||||||
|
import { PlaywrightAuthSessionService } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
|
||||||
|
import type { PlaywrightBrowserSession } from '../../../packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
|
||||||
|
import type { SessionCookieStore } from '../../../packages/infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||||
|
import type { IPlaywrightAuthFlow } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
|
||||||
|
import type { ILogger } from '../../../packages/application/ports/ILogger';
|
||||||
|
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
|
||||||
|
import { Result } from '../../../packages/shared/result/Result';
|
||||||
|
|
||||||
|
describe('PlaywrightAuthSessionService.initiateLogin browser mode behaviour', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
let mockBrowserSession: PlaywrightBrowserSession;
|
||||||
|
let mockCookieStore: SessionCookieStore;
|
||||||
|
let mockAuthFlow: IPlaywrightAuthFlow;
|
||||||
|
let mockLogger: ILogger;
|
||||||
|
let mockPage: Page;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
|
||||||
|
mockLogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPage = {
|
||||||
|
goto: vi.fn().mockResolvedValue(undefined),
|
||||||
|
url: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
|
||||||
|
isClosed: vi.fn().mockReturnValue(false),
|
||||||
|
} as unknown as Page;
|
||||||
|
|
||||||
|
mockBrowserSession = {
|
||||||
|
connect: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getPersistentContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
|
||||||
|
getContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
|
||||||
|
getPage: vi.fn().mockReturnValue(mockPage),
|
||||||
|
getUserDataDir: vi.fn().mockReturnValue(''),
|
||||||
|
} as unknown as PlaywrightBrowserSession;
|
||||||
|
|
||||||
|
mockCookieStore = {
|
||||||
|
read: vi.fn().mockResolvedValue({
|
||||||
|
cookies: [],
|
||||||
|
origins: [],
|
||||||
|
}),
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
delete: vi.fn().mockResolvedValue(undefined),
|
||||||
|
validateCookies: vi.fn().mockReturnValue(AuthenticationState.UNKNOWN),
|
||||||
|
getSessionExpiry: vi.fn(),
|
||||||
|
getValidCookiesForUrl: vi.fn().mockReturnValue([]),
|
||||||
|
} as unknown as SessionCookieStore;
|
||||||
|
|
||||||
|
mockAuthFlow = {
|
||||||
|
getLoginUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/login'),
|
||||||
|
getPostLoginLandingUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
|
||||||
|
isLoginUrl: vi.fn().mockReturnValue(false),
|
||||||
|
isAuthenticatedUrl: vi.fn().mockReturnValue(true),
|
||||||
|
isLoginSuccessUrl: vi.fn().mockReturnValue(true),
|
||||||
|
detectAuthenticatedUi: vi.fn().mockResolvedValue(true),
|
||||||
|
detectLoginUi: vi.fn().mockResolvedValue(false),
|
||||||
|
navigateToAuthenticatedArea: vi.fn().mockResolvedValue(undefined),
|
||||||
|
waitForPostLoginRedirect: vi.fn().mockResolvedValue(true),
|
||||||
|
} as unknown as IPlaywrightAuthFlow;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
return new PlaywrightAuthSessionService(
|
||||||
|
mockBrowserSession,
|
||||||
|
mockCookieStore,
|
||||||
|
mockAuthFlow,
|
||||||
|
mockLogger,
|
||||||
|
{
|
||||||
|
navigationTimeoutMs: 1000,
|
||||||
|
loginWaitTimeoutMs: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('always forces headed browser for login regardless of browser mode configuration', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const result = await service.initiateLogin();
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
expect(mockBrowserSession.connect).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates the headed page to the non-blank login URL', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const result = await service.initiateLogin();
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
expect(mockAuthFlow.getLoginUrl).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
expect(mockPage.goto).toHaveBeenCalledWith(
|
||||||
|
'https://members-ng.iracing.com/login',
|
||||||
|
expect.objectContaining({
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calledUrl = (mockPage.goto as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||||
|
expect(calledUrl).not.toEqual('about:blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates connection failure from browserSession.connect', async () => {
|
||||||
|
(mockBrowserSession.connect as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
error: 'boom',
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const result = await service.initiateLogin();
|
||||||
|
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const err = result.unwrapErr();
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.message).toContain('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs explicit headed login message for human companion flow', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const result = await service.initiateLogin();
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'Opening login in headed Playwright browser (forceHeaded=true)',
|
||||||
|
expect.objectContaining({ forceHeaded: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { Page, Locator } from 'playwright';
|
||||||
|
import { PlaywrightAuthSessionService } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
|
||||||
|
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
|
||||||
|
import { BrowserAuthenticationState } from '../../../packages/domain/value-objects/BrowserAuthenticationState';
|
||||||
|
import type { ILogger } from '../../../packages/application/ports/ILogger';
|
||||||
|
import type { Result } from '../../../packages/shared/result/Result';
|
||||||
|
import type { PlaywrightBrowserSession } from '../../../packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
|
||||||
|
import type { SessionCookieStore } from '../../../packages/infrastructure/adapters/automation/auth/SessionCookieStore';
|
||||||
|
import type { IPlaywrightAuthFlow } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
|
||||||
|
|
||||||
|
describe('PlaywrightAuthSessionService.verifyPageAuthentication', () => {
|
||||||
|
function createService(deps: {
|
||||||
|
pageUrl: string;
|
||||||
|
hasLoginUi: boolean;
|
||||||
|
hasAuthUi: boolean;
|
||||||
|
cookieState: AuthenticationState;
|
||||||
|
}) {
|
||||||
|
const mockLogger: ILogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLocator: Locator = {
|
||||||
|
first: vi.fn().mockReturnThis(),
|
||||||
|
isVisible: vi.fn().mockImplementation(async () => deps.hasLoginUi),
|
||||||
|
} as unknown as Locator;
|
||||||
|
|
||||||
|
const mockPage: Page = {
|
||||||
|
url: vi.fn().mockReturnValue(deps.pageUrl),
|
||||||
|
locator: vi.fn().mockReturnValue(mockLocator),
|
||||||
|
} as unknown as Page;
|
||||||
|
|
||||||
|
const mockBrowserSession: PlaywrightBrowserSession = {
|
||||||
|
getPersistentContext: vi.fn().mockReturnValue(null),
|
||||||
|
getContext: vi.fn().mockReturnValue(null),
|
||||||
|
getPage: vi.fn().mockReturnValue(mockPage),
|
||||||
|
} as unknown as PlaywrightBrowserSession;
|
||||||
|
|
||||||
|
const mockCookieStore: SessionCookieStore = {
|
||||||
|
read: vi.fn().mockResolvedValue({
|
||||||
|
cookies: [{ name: 'XSESSIONID', value: 'abc', domain: 'members-ng.iracing.com', path: '/', expires: -1 }],
|
||||||
|
origins: [],
|
||||||
|
}),
|
||||||
|
validateCookies: vi.fn().mockReturnValue(deps.cookieState),
|
||||||
|
getSessionExpiry: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
} as unknown as SessionCookieStore;
|
||||||
|
|
||||||
|
const mockAuthFlow: IPlaywrightAuthFlow = {
|
||||||
|
getLoginUrl: () => 'https://members-ng.iracing.com/login',
|
||||||
|
getPostLoginLandingUrl: () => 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||||
|
isLoginUrl: (url: string) => url.includes('/login'),
|
||||||
|
isAuthenticatedUrl: (url: string) => url.includes('/web/racing/hosted'),
|
||||||
|
isLoginSuccessUrl: (url: string) => url.includes('/web/racing/hosted'),
|
||||||
|
detectAuthenticatedUi: vi.fn().mockResolvedValue(deps.hasAuthUi),
|
||||||
|
detectLoginUi: vi.fn(),
|
||||||
|
navigateToAuthenticatedArea: vi.fn(),
|
||||||
|
waitForPostLoginRedirect: vi.fn(),
|
||||||
|
} as unknown as IPlaywrightAuthFlow;
|
||||||
|
|
||||||
|
const service = new PlaywrightAuthSessionService(
|
||||||
|
mockBrowserSession,
|
||||||
|
mockCookieStore,
|
||||||
|
mockAuthFlow,
|
||||||
|
mockLogger,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, mockCookieStore, mockAuthFlow, mockPage };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('treats cookies-valid + login UI as EXPIRED (page wins over cookies)', async () => {
|
||||||
|
const { service } = createService({
|
||||||
|
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||||
|
hasLoginUi: true,
|
||||||
|
hasAuthUi: false,
|
||||||
|
cookieState: AuthenticationState.AUTHENTICATED,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
|
||||||
|
const browserState = result.unwrap();
|
||||||
|
expect(browserState.getCookieValidity()).toBe(true);
|
||||||
|
expect(browserState.getPageAuthenticationStatus()).toBe(false);
|
||||||
|
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
|
||||||
|
expect(browserState.requiresReauthentication()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats cookies-valid + authenticated UI without login UI as AUTHENTICATED', async () => {
|
||||||
|
const { service } = createService({
|
||||||
|
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||||
|
hasLoginUi: false,
|
||||||
|
hasAuthUi: true,
|
||||||
|
cookieState: AuthenticationState.AUTHENTICATED,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
|
||||||
|
const browserState = result.unwrap();
|
||||||
|
expect(browserState.getCookieValidity()).toBe(true);
|
||||||
|
expect(browserState.getPageAuthenticationStatus()).toBe(true);
|
||||||
|
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
|
||||||
|
expect(browserState.requiresReauthentication()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user