export type PhoneSpec = {
role: 'rider' | 'driver';
id: string;
name: string;
car?: string;
plate?: string;
rating?: number;
location?: { latitude: number; longitude: number };
driving?: boolean;
};
* One multi-phone scenario. Scenarios live in cypress/scenarios/ and are registered by
* thin spec files in cypress/e2e/, so the same steps can back more than one spec.
*/
export type Scenario = { title: string; run: () => void };
export const defineScenario = (title: string, run: () => void): Scenario => ({ title, run });
export type DispatchState = {
policy: AuctionPolicy;
pricing: PricingConfig;
devices: (PhoneSpec & { online: boolean; driving: boolean; jobs: string[]; position: { latitude: number; longitude: number } | null })[];
rides: {
id: string;
riderId: string;
status: 'auction' | 'matched' | 'completed' | 'unmatched' | 'cancelled';
destination: string;
rideTypeId: string;
quotedFare: number;
surge: number;
pickup: { latitude: number; longitude: number };
rounds: {
number: number;
price: number;
final: boolean;
closesAt: number;
invited: string[];
declined: string[];
acceptedBy: string | null;
lateAccepts: string[];
}[];
winnerId: string | null;
fare: number | null;
}[];
};
declare global {
namespace Cypress {
interface Chainable {
* Clear all rides on the dispatch server and configure it for a scenario:
* - policy: auction settings over TEST_POLICY (roundDurationMs is stretched on slow runs)
* - pricing: pricing settings over TEST_PRICING
* - rates: 'test' (default) for TEST_PRICING's simple UberX card, 'real' for the
* server's actual rate cards
*/
resetDispatch(options?: ResetOptions): Chainable<DispatchState>;
openPhones(phones: PhoneSpec[]): Chainable<void>;
phone(id: string): Chainable<JQuery<HTMLElement>>;
dispatchState(): Chainable<DispatchState>;
waitForDispatch(check: (state: DispatchState) => boolean, description: string): Chainable<DispatchState>;
requestRide(riderId: string, query: string, placeTitle: string): Chainable<void>;
accept(driverId: string, options?: { rider?: string }): Chainable<void>;
pass(driverId: string, options?: { rider?: string }): Chainable<void>;
* Hold a phone's link to dispatch: it keeps running on what it last saw, what it sends
* is queued and what it receives is buffered. Use it to stage races.
*/
pausePhone(id: string): Chainable<void>;
resumePhone(id: string): Chainable<void>;
startDriving(driverId: string): Chainable<void>;
goOffline(driverId: string): Chainable<void>;
}
}
}
import type { AuctionPolicy, PricingConfig, PricingOverrides } from '../../src/lib/dispatch/protocol';
import '../../src/lib/dispatch/testHooks';
import { scaled, stepDelayMs, typeDelayMs } from './pace';
export const tid = (id: string) => `[data-testid="${id}"]`;
const dispatchUrl = () => Cypress.expose('dispatchUrl') as string;
* The policy every scenario starts from, spelled out so tests don't depend on the server's
* defaults. With the fixture's $11.00 quote, rounds offer $8.80, $9.35, $9.90, $10.45, $11.00.
*/
export const TEST_POLICY: AuctionPolicy = {
startMultiplier: 0.8,
stepMultiplier: 0.05,
maxMultiplier: 1,
roundDurationMs: 15_000,
};
* Pricing every scenario starts from. The simple UberX card makes the fixture's 5 km,
* 10 min trip cost exactly $11.00 (2.50 + 10 × 0.85), and surge is off so prices don't
* depend on how many requests a scenario makes. Drivers earn the default 75%.
*/
export const TEST_PRICING: PricingOverrides = {
rateCards: { sf: { x: { baseFare: 2.5, perMile: 0, perMinute: 0.85, bookingFee: 0, minimumFare: 7 } } },
surge: { enabled: false },
};
export type ResetOptions = {
policy?: Partial<AuctionPolicy>;
pricing?: PricingOverrides;
rates?: 'test' | 'real';
};
Cypress.Commands.add('resetDispatch', ({ policy: policyOverrides = {}, pricing: pricingOverrides = {}, rates = 'test' } = {}) => {
const policy = { ...TEST_POLICY, ...policyOverrides };
policy.roundDurationMs = scaled(policy.roundDurationMs);
const pricing: PricingOverrides = {
rateCards: { ...(rates === 'test' ? TEST_PRICING.rateCards : {}), ...pricingOverrides.rateCards },
surge: { ...TEST_PRICING.surge, ...pricingOverrides.surge },
driverShare: pricingOverrides.driverShare,
quoteTtlMs: pricingOverrides.quoteTtlMs,
};
return cy.request('POST', `${dispatchUrl()}/test/reset`, { policy, pricing }).its('body');
});
Cypress.Commands.add('dispatchState', () => {
return cy.request({ url: `${dispatchUrl()}/state`, log: false }).its('body', { log: false });
});
Cypress.Commands.add('waitForDispatch', (check, description) => {
Cypress.log({ name: 'waitForDispatch', message: description });
const deadline = Date.now() + Cypress.config('defaultCommandTimeout');
const poll = (): Cypress.Chainable<DispatchState> =>
cy.dispatchState().then((state) => {
if (check(state)) return cy.wrap(state, { log: false });
if (Date.now() > deadline) throw new Error(`Timed out waiting for dispatch: ${description}`);
return cy.wait(200, { log: false }).then(poll);
});
return poll();
});
Cypress.Commands.add('openPhones', (phones) => {
const specs = phones.map((p) => {
const options = {
car: p.car,
plate: p.plate,
rating: p.rating,
lat: p.location?.latitude,
lng: p.location?.longitude,
driving: p.driving === false ? 0 : undefined,
};
const extras = Object.entries(options)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${value}`);
return [p.role, p.id, p.name, ...extras].map((part) => encodeURIComponent(String(part))).join(':');
});
cy.visit(`/sim.html?phones=${encodeURIComponent(specs.join(','))}`);
cy.wrap(null, { log: false, timeout: 120_000 }).should(() => {
for (const p of phones) {
const body = Cypress.$(`iframe[data-phone="${p.id}"]`)[0] as HTMLIFrameElement | undefined;
const ready = p.role === 'driver' ? tid('driver-status') : tid('where-to');
expect(body?.contentDocument?.querySelector(ready), `${p.id} rendered`).to.exist;
}
});
const drives = (p: PhoneSpec) => p.role === 'driver' && p.driving !== false;
for (const p of phones) {
if (p.role === 'driver') cy.phone(p.id).find(tid('driver-status')).should('have.text', drives(p) ? 'Driving' : 'Offline');
}
cy.waitForDispatch(
(state) => phones.every((p) => state.devices.some((d) => d.id === p.id && d.online && d.driving === drives(p))),
'all phones connected, drivers in their starting mode',
);
});
Cypress.Commands.add('phone', (id) => {
Cypress.log({ name: 'phone', message: id });
if (stepDelayMs() > 0) {
cy.get('iframe[data-phone]', { log: false }).then((frames) => {
frames.removeClass('acting').filter(`[data-phone="${id}"]`).addClass('acting');
});
cy.wait(stepDelayMs(), { log: false });
}
return cy
.get(`iframe[data-phone="${id}"]`, { log: false })
.its('0.contentDocument.body', { log: false })
.then((body) => cy.wrap(body as HTMLElement, { log: false }));
});
Cypress.Commands.add('requestRide', (riderId, query, placeTitle) => {
cy.phone(riderId).find(tid('where-to')).click();
cy.phone(riderId).find(tid('destination-input')).type(query, { delay: typeDelayMs() });
cy.phone(riderId).contains(placeTitle).click();
cy.phone(riderId).find(tid('confirm-ride')).click();
cy.phone(riderId).find(tid('ride-status')).should('have.text', 'Finding your driver');
});
function offerOn(driverId: string, rider?: string) {
const offers = cy.phone(driverId).find(tid('offer'));
return rider ? offers.filter(`:contains("${rider} →")`).should('have.length', 1) : offers.should('have.length', 1);
}
Cypress.Commands.add('accept', (driverId, options = {}) => {
offerOn(driverId, options.rider).find(tid('accept-offer')).click();
});
Cypress.Commands.add('pass', (driverId, options = {}) => {
offerOn(driverId, options.rider).find(tid('decline-offer')).click();
});
beforeEach(() => {
cy.intercept('GET', /photon\.komoot\.io\/api/, { fixture: 'photon-ferry-building.json' });
cy.intercept('GET', /router\.project-osrm\.org\/route/, { fixture: 'osrm-5km.json' });
});
function dispatchLink(id: string) {
return cy.phone(id).then((body) => {
const link = body[0].ownerDocument.defaultView?.__dispatchLink;
if (!link) throw new Error(`${id} has no dispatch link hook (web dev builds only)`);
return link;
});
}
function markPaused(id: string, paused: boolean) {
cy.get(`iframe[data-phone="${id}"]`, { log: false }).invoke({ log: false }, 'toggleClass', 'paused', paused);
}
Cypress.Commands.add('pausePhone', (id) => {
Cypress.log({ name: 'pausePhone', message: id });
dispatchLink(id).then((link) => link.hold());
markPaused(id, true);
});
Cypress.Commands.add('resumePhone', (id) => {
Cypress.log({ name: 'resumePhone', message: id });
dispatchLink(id).then((link) => link.release());
markPaused(id, false);
});
function setDriving(driverId: string, driving: boolean) {
cy.phone(driverId).find(tid(driving ? 'start-driving' : 'stop-driving')).click();
cy.phone(driverId).find(tid('driver-status')).should('have.text', driving ? 'Driving' : 'Offline');
cy.waitForDispatch(
(state) => state.devices.some((d) => d.id === driverId && d.driving === driving),
`${driverId} ${driving ? 'driving' : 'offline'}`,
);
}
Cypress.Commands.add('startDriving', (driverId) => setDriving(driverId, true));
Cypress.Commands.add('goOffline', (driverId) => setDriving(driverId, false));