// Commands for driving the multi-phone simulator (public/sim.html). // // Each phone is an iframe running the web build of the app, tagged data-phone="". // cy.phone(id) yields that iframe's , so everything after it is scoped to one phone: // // cy.phone('maria').find(tid('offer')).should('contain.text', 'Ferry Building'); export type PhoneSpec = { role: 'rider' | 'driver'; id: string; name: string; car?: string; plate?: string; rating?: number; /** Where the phone's mocked GPS puts it; downtown San Francisco if left out. */ location?: { latitude: number; longitude: number }; /** Drivers only: start in driving mode (the default) or offline. */ 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 { // eslint-disable-next-line @typescript-eslint/no-namespace 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; /** Load the simulator with these phones and wait until each is connected and on screen. */ openPhones(phones: PhoneSpec[]): Chainable; /** The of one phone's iframe. */ phone(id: string): Chainable>; dispatchState(): Chainable; /** Poll the dispatch server until `check` passes. */ waitForDispatch(check: (state: DispatchState) => boolean, description: string): Chainable; /** Rider searches for a place, picks it, and requests the default ride type. */ requestRide(riderId: string, query: string, placeTitle: string): Chainable; /** Driver accepts the suggested fare. `rider` picks the offer when the driver has several. */ accept(driverId: string, options?: { rider?: string }): Chainable; /** Driver passes on the suggested fare. */ pass(driverId: string, options?: { rider?: string }): Chainable; /** * 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; /** Send the phone's queued messages in order, then deliver what it missed. */ resumePhone(id: string): Chainable; /** Driver taps Start driving, and dispatch has registered it. */ startDriving(driverId: string): Chainable; /** Driver taps Go offline, and dispatch has registered it. */ goOffline(driverId: string): Chainable; } } } import type { AuctionPolicy, PricingConfig, PricingOverrides } from '../../src/lib/dispatch/protocol'; import '../../src/lib/dispatch/testHooks'; import { scaled, stepDelayMs, typeDelayMs } from './pace'; /** Selector for a React Native `testID`, which react-native-web renders as data-testid. */ 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; 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 => 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(','))}`); // The first load can wait on Metro compiling the web bundle. 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) { // Show which phone is about to act, then give the viewer a moment to see it. 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(() => { // Keep scenarios independent of the live geocoder and router: every search finds the // Ferry Building and every route is 5 km / 10 min (so UberX is quoted $11.00). 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));