Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/TEST_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Status source: `docs/STATUS.md`.

Playwright starts an isolated strict-port RFMS dev server on `127.0.0.1:5174` by default. Set `PLAYWRIGHT_PORT` to another valid TCP port (1-65535), or use `PLAYWRIGHT_BASE_URL` only for intentional external-server validation.

## Automated Gates

| Gate | Command | Current status | Required status |
Expand Down
27 changes: 21 additions & 6 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { defineConfig, devices } from '@playwright/test';

const playwrightPort = process.env.PLAYWRIGHT_PORT ?? '5174';
const parsedPlaywrightPort = Number(playwrightPort);
if (
!/^\d+$/.test(playwrightPort) ||
!Number.isInteger(parsedPlaywrightPort) ||
parsedPlaywrightPort < 1 ||
parsedPlaywrightPort > 65535
) {
throw new Error(`Invalid PLAYWRIGHT_PORT: ${playwrightPort}`);
}
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${parsedPlaywrightPort}`;
const useExternalServer = Boolean(process.env.PLAYWRIGHT_BASE_URL);

export default defineConfig({
testDir: './e2e',
snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}-{projectName}{ext}',
Expand All @@ -9,7 +22,7 @@ export default defineConfig({
workers: process.env.CI ? '50%' : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
baseURL,
trace: 'on-first-retry',
},
projects: [
Expand Down Expand Up @@ -46,9 +59,11 @@ export default defineConfig({
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
webServer: useExternalServer
? undefined
: {
command: `npm run dev -- --host 127.0.0.1 --port ${parsedPlaywrightPort} --strictPort`,
url: baseURL,
reuseExistingServer: process.env.PLAYWRIGHT_REUSE_SERVER === '1',
},
});
31 changes: 31 additions & 0 deletions server/src/__tests__/adapter-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { describe, expect, it } from 'vitest';
import { getAdapterHealth, toAdapterCapabilities } from '../aircraft-adapters/adapter-health';
import { MockSimConnectAdapter } from '../aircraft-adapters/mock-simconnect';

class CapabilityAdapter extends MockSimConnectAdapter {
readonly capabilities: string[];

constructor(capabilities: string[]) {
super();
this.capabilities = capabilities;
}
}

describe('adapter health contract', () => {
it('maps legacy string capabilities to structured production capabilities', () => {
const adapter = new MockSimConnectAdapter();
Expand All @@ -16,6 +25,28 @@ describe('adapter health contract', () => {
);
});

it('normalizes real adapter capability aliases before mapping them', () => {
const adapter = new CapabilityAdapter([
' Display ',
'POSITION',
'heading',
'speed',
'altitude',
'radios',
'flightPlan',
'AIRAC',
'playback',
'',
]);

expect(toAdapterCapabilities(adapter)).toEqual({
instruments: ['CDU', 'ND'],
commands: ['keyPress', 'lskPress'],
data: ['display', 'telemetry', 'flightPlan', 'navCycle', 'adapterVersion'],
replay: true,
});
});

it('reports profile-bound health without claiming live validation', async () => {
const adapter = new MockSimConnectAdapter();
await adapter.connect();
Expand Down
75 changes: 64 additions & 11 deletions server/src/__tests__/bridge-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import WebSocket from 'ws';
import type { ServerMessage } from '@virtual-cdu/shared';
import { createBridgeServer, type BridgeServer } from '../bridge-server';
import { MockSimConnectAdapter } from '../aircraft-adapters/mock-simconnect';

let bridge: BridgeServer | null = null;
const originalAuthToken = process.env.AUTH_TOKEN;

class MessageCollector {
private messages: ServerMessage[] = [];
Expand Down Expand Up @@ -44,12 +45,54 @@ function waitOpen(ws: WebSocket): Promise<void> {
});
}

function waitRejectedUpgrade(ws: WebSocket): Promise<number> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error('Timed out waiting for rejected upgrade'));
}, 2000);

function cleanup() {
clearTimeout(timer);
ws.off('open', handleOpen);
ws.off('unexpected-response', handleUnexpectedResponse);
ws.off('error', handleError);
}

function handleOpen() {
cleanup();
reject(new Error('WebSocket unexpectedly opened'));
}

function handleUnexpectedResponse(_request: unknown, response: { statusCode?: number; resume: () => void }) {
cleanup();
response.resume();
resolve(response.statusCode ?? 0);
}

function handleError(error: Error) {
cleanup();
reject(error);
}

ws.once('open', handleOpen);
ws.once('unexpected-response', handleUnexpectedResponse);
ws.once('error', handleError);
});
}

describe('bridge server', () => {
beforeEach(() => {
delete process.env.AUTH_TOKEN;
});

afterEach(async () => {
if (bridge) {
await bridge.stop();
bridge = null;
}
if (originalAuthToken === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = originalAuthToken;
});

it('connects through the mock adapter and broadcasts CONTROL-mode display data', async () => {
Expand Down Expand Up @@ -170,17 +213,27 @@ describe('bridge server', () => {
headers: { Origin: 'https://evil.example.test' },
});

await expect(
new Promise<void>((resolve, reject) => {
ws.once('open', () => resolve());
ws.once('unexpected-response', (_request, response) => {
reject(new Error(`unexpected-response:${response.statusCode}`));
});
ws.once('error', reject);
}),
).rejects.toThrow('unexpected-response:403');
await expect(waitRejectedUpgrade(ws)).resolves.toBe(403);
});

ws.close();
it('rejects clients with an invalid AUTH_TOKEN using an HTTP status code', async () => {
process.env.AUTH_TOKEN = 'expected-token';
bridge = createBridgeServer({ aircraft: new MockSimConnectAdapter() });
const port = await bridge.start();
const ws = new WebSocket(`ws://127.0.0.1:${port}?token=wrong-token`);

await expect(waitRejectedUpgrade(ws)).resolves.toBe(401);
});

it('keeps adapter capabilities off the unauthenticated health endpoint', async () => {
bridge = createBridgeServer({ aircraft: new MockSimConnectAdapter() });
const port = await bridge.start();

const response = await fetch(`http://127.0.0.1:${port}/health`);
const health = await response.json();

expect(health).not.toHaveProperty('capabilities');
expect(health).not.toHaveProperty('structuredCapabilities');
});

it('sets baseline security headers on HTTP responses', async () => {
Expand Down
49 changes: 41 additions & 8 deletions server/src/aircraft-adapters/adapter-health.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
import type { AdapterCapabilities, AdapterHealth } from '@virtual-cdu/shared';
import type { IAircraftAdapter } from './IAircraftAdapter';

type CommandCapability = AdapterCapabilities['commands'][number];
type DataCapability = AdapterCapabilities['data'][number];

const DISPLAY_CAPABILITIES = new Set(['display', 'displayreadback', 'cdudisplay', 'mcdudisplay']);
const TELEMETRY_CAPABILITIES = new Set([
'aircraftstate',
'position',
'heading',
'speed',
'altitude',
'radios',
'telemetry',
]);
const FLIGHT_PLAN_CAPABILITIES = new Set(['flightplan', 'route', 'fpln']);
const NAV_CYCLE_CAPABILITIES = new Set(['navcycle', 'navdata', 'airac']);
const REPLAY_CAPABILITIES = new Set(['latencysimulation', 'replay', 'playback']);

function normalizeCapabilities(capabilities: readonly string[]): Set<string> {
return new Set(capabilities.map((capability) => capability.trim().toLowerCase()).filter(Boolean));
}

function hasAny(raw: Set<string>, candidates: Set<string>): boolean {
for (const candidate of candidates) {
if (raw.has(candidate)) return true;
}
return false;
}

export function toAdapterCapabilities(adapter: IAircraftAdapter): AdapterCapabilities {
const raw = new Set(adapter.capabilities);
const raw = normalizeCapabilities(adapter.capabilities);
const commands: CommandCapability[] = ['keyPress', 'lskPress'];
const data: DataCapability[] = [
...(hasAny(raw, DISPLAY_CAPABILITIES) ? (['display'] as DataCapability[]) : []),
...(hasAny(raw, TELEMETRY_CAPABILITIES) ? (['telemetry'] as DataCapability[]) : []),
...(hasAny(raw, FLIGHT_PLAN_CAPABILITIES) ? (['flightPlan'] as DataCapability[]) : []),
...(hasAny(raw, NAV_CYCLE_CAPABILITIES) ? (['navCycle'] as DataCapability[]) : []),
'adapterVersion',
];

return {
instruments: adapter.aircraftType === 'AIRBUS_A320' ? ['MCDU', 'ND'] : ['CDU', 'ND'],
commands: ['keyPress', 'lskPress'],
data: [
...(raw.has('displayReadback') ? ['display' as const] : []),
...(raw.has('aircraftState') ? ['telemetry' as const] : []),
'adapterVersion',
],
replay: raw.has('latencySimulation'),
commands,
data,
replay: hasAny(raw, REPLAY_CAPABILITIES),
};
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/bridge-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function createBridgeServer(options: BridgeServerOptions = {}): BridgeSer
if (token !== authToken) {
metrics.authRejected();
logger.warn(LogEvent.WS_AUTH_REJECTED, { ip: getClientIp(req) });
done(false, 4001, 'Authentication failed');
done(false, 401, 'Authentication failed');
return;
}
}
Expand Down
6 changes: 6 additions & 0 deletions shared/src/__tests__/lskDispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ describe('dispatchLskAction', () => {
expect(result.success?.patch).toBeDefined();
});

it('dispatches route action (set_co_route)', () => {
const result = dispatchLskAction({ state: makeState(), action: 'set_co_route', scratchpad: 'KJFKDCA1' });
expect(result.handled).toBe(true);
expect(getPatch(result).pendingRoute?.companyRoute).toBe('KJFKDCA1');
});

it('dispatches performance action (set_crz_alt)', () => {
const result = dispatchLskAction({ state: makeState(), action: 'set_crz_alt', scratchpad: '350' });
expect(result.handled).toBe(true);
Expand Down
34 changes: 34 additions & 0 deletions shared/src/__tests__/rendererGrammar.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { validateDisplayGrid } from '../fmc/displayGridValidation';
import { displayDataToGrid } from '../fmc/displayGrid';
import { dispatchLskAction } from '../fmc/actionHandlers/lskDispatcher';
import { buildInitialFMCState } from '../fmc/initialState';
import { getPageRenderer } from '../fmc/pages/index';
import { getAirbusPageRenderer } from '../fmc/pages/airbus/index';
Expand Down Expand Up @@ -78,6 +79,14 @@ const airbusData: Partial<FMCState> = {
ident: { aircraftType: 'A320-214', engRating: 'CFM56-5B4', navDataVersion: '2501', opProgram: 'FMS2' },
};

const rteScratchpadByAction: Record<string, string> = {
set_origin: 'KJFK',
set_dest: 'KDCA',
set_co_route: 'KJFKDCA1',
set_flt_no: 'UA123',
set_route: 'KJFK DCT RBV DCT KDCA',
};

describe('Boeing renderer grammar conformance', () => {
const boeingPages: string[] = ['IDENT', 'POS_INIT', 'RTE', 'DEP_ARR', 'PERF_INIT', 'TAKEOFF_REF', 'LEGS', 'PROGRESS'];

Expand All @@ -95,6 +104,31 @@ describe('Boeing renderer grammar conformance', () => {
}
});
}

it('Boeing RTE emits only dispatcher-recognized LSK actions', () => {
const renderer = getPageRenderer('RTE');
if (!renderer) throw new Error('No renderer for RTE');

for (const rteSubPage of [0, 1]) {
const rendererState = state({
...boeingData,
currentPage: 'RTE',
rteSubPage,
route: { origin: 'KJFK', destination: 'KDCA', flightNumber: 'UA123', companyRoute: '', routeString: '' },
});
const data = renderer(rendererState);

for (const [slot, action] of Object.entries(data.lskActions)) {
if (!action) continue;
const result = dispatchLskAction({
state: rendererState,
action,
scratchpad: rteScratchpadByAction[action] ?? '',
});
expect(result.handled, `RTE ${rteSubPage + 1}/2 ${slot} emitted unhandled action "${action}"`).toBe(true);
}
}
});
});

describe('Airbus renderer grammar conformance', () => {
Expand Down
38 changes: 38 additions & 0 deletions shared/src/__tests__/routeActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,44 @@ describe('handleSetFltNo (via dispatcher)', () => {
});
});

describe('handleSetCoRoute (via dispatcher)', () => {
it('returns handled:false when scratchpad is empty', () => {
const result = handleRouteAction('set_co_route', makeState(), '');
expect(result.handled).toBe(false);
});

it('returns failure for malformed company route identifiers', () => {
const result = handleRouteAction('set_co_route', makeState(), 'KJFK KDCA');
expect(result.handled).toBe(true);
expect(result.failure).toMatchObject({
code: 'INVALID_FORMAT',
text: 'INVALID ENTRY',
source: 'routeActions.set_co_route',
});
});

it('stages a company route for EXEC without replacing existing route fields', () => {
const state = makeState({
route: { origin: 'KJFK', destination: 'KDCA', flightNumber: 'AAL123', companyRoute: '', routeString: '' },
});
const result = handleRouteAction('set_co_route', state, 'kjfkdca1');

expect(result.handled).toBe(true);
expect(result.success?.clearScratchpad).toBe(true);
const patch = getPatch(result);
expect(patch.pendingRoute).toMatchObject({
origin: 'KJFK',
destination: 'KDCA',
flightNumber: 'AAL123',
companyRoute: 'KJFKDCA1',
coRoute: 'KJFKDCA1',
});
expect(patch.isModified).toBe(true);
expect(patch.execLit).toBe(true);
expect(state.route.companyRoute).toBe('');
});
});

describe('handleSetRoute (via dispatcher)', () => {
it('returns handled:false when scratchpad is empty', () => {
const result = handleRouteAction('set_route', makeState(), '');
Expand Down
Loading
Loading