Split auction scenarios into one Cypress spec each

Scenario steps move to cypress/scenarios/ and each gets a thin spec in
cypress/e2e/, so the same steps can back more than one spec. Shared
phones move to cypress/support/phones.ts, and "Run all specs" is
enabled in the Cypress UI for playing every scenario back to back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-09-30 06:39:14 -08:00
parent 64ab853eeb
commit 3a9944489f
19 changed files with 239 additions and 176 deletions

View file

@ -17,5 +17,7 @@ export default defineConfig({
pageLoadTimeout: 120_000,
defaultCommandTimeout: 10_000,
video: false,
// Adds "Run all specs" to `npm run e2e:open`, to play every scenario back to back.
experimentalRunAllSpecs: true,
},
});

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/auction-deadline';
it(scenario.title, scenario.run);

View file

@ -1,168 +0,0 @@
import { tid, type PhoneSpec } from '../support/e2e';
// Multi-phone scenarios for the ride price auction. When a rider requests a ride, every
// online, idle driver gets a sealed-bid invitation capped at the rider's quoted fare.
// The lowest bid wins; the auction closes once every driver has answered or time runs out.
//
// The fixtures make every trip 5 km / 10 min, so UberX is quoted at $11.00.
const alice: PhoneSpec = { role: 'rider', id: 'alice', name: 'Alice' };
const bob: PhoneSpec = { role: 'rider', id: 'bob', name: 'Bob' };
const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', car: 'Toyota Camry · Silver', plate: '7KDL294', rating: 4.95 };
const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James', car: 'Honda Accord · Black', plate: '8TRW115', rating: 4.88 };
const aisha: PhoneSpec = { role: 'driver', id: 'aisha', name: 'Aisha', car: 'Tesla Model 3 · White', plate: '9EVA662', rating: 4.97 };
describe('ride price auction', () => {
it('awards the ride to the lowest bid and runs the trip end to end', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james, aisha]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
// Every driver is invited, capped at the quoted fare.
for (const driver of ['maria', 'james', 'aisha']) {
cy.phone(driver).find(tid('offer-destination')).should('have.text', 'Alice → Ferry Building');
cy.phone(driver).find(tid('offer-max-fare')).should('have.text', 'Rider pays up to $11.00');
}
cy.bid('maria', 10);
cy.phone('maria').find(tid('offer-my-bid')).should('contain.text', 'You bid $10.00');
cy.phone('alice').find(tid('offer-count')).should('have.text', '1 offer so far');
cy.bid('james', 8.5);
cy.phone('alice').find(tid('offer-count')).should('have.text', '2 offers so far');
// The last driver answering closes the auction without waiting out the clock.
cy.pass('aisha');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Driver found');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$8.50');
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
cy.phone('alice').find(tid('matched-driver')).should('contain.text', '8TRW115');
cy.phone('james').find(tid('job-fare')).should('have.text', 'Fare $8.50');
cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride');
cy.phone('aisha').find(tid('driver-idle')).should('exist');
cy.phone('aisha').find(tid('driver-result')).should('not.exist');
cy.dispatchState().then(({ rides }) => {
expect(rides).to.have.length(1);
expect(rides[0]).to.include({ status: 'matched', winnerId: 'james', fare: 8.5, maxFare: 11 });
expect(rides[0].declined).to.deep.equal(['aisha']);
});
// The winning driver finishes the trip; the rider's phone follows.
cy.phone('james').find(tid('complete-trip')).click();
cy.phone('james').find(tid('driver-result')).should('have.text', 'Trip complete · you earned $8.50');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'You’ve arrived');
cy.phone('alice').should('contain.text', 'Thanks for riding with James.');
cy.phone('alice').find(tid('trip-done')).click();
cy.phone('alice').find(tid('where-to')).should('exist');
});
it('breaks a tie in favour of the earlier bid', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.bid('james', 9);
cy.phone('james').find(tid('offer-my-bid')).should('exist');
cy.bid('maria', 9);
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride');
});
it('does not let a driver bid above the quoted fare', () => {
cy.resetDispatch();
cy.openPhones([alice, maria]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.phone('maria').find(tid('bid-input')).clear().type('11.01');
cy.phone('maria').find(tid('submit-bid')).should('have.attr', 'aria-disabled', 'true');
cy.phone('maria').find(tid('bid-input')).clear().type('11');
cy.phone('maria').find(tid('submit-bid')).should('not.have.attr', 'aria-disabled');
});
it('closes on the deadline and awards whoever did bid', () => {
cy.resetDispatch({ auctionDurationMs: 4000 });
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.bid('maria', 10.75);
// James never answers; the auction stays open until the window runs out.
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver');
cy.phone('alice').find(tid('ride-status'), { timeout: 8000 }).should('have.text', 'Driver found');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.75');
cy.phone('james').find(tid('driver-result')).should('have.text', 'That request closed before you answered');
cy.phone('james').find(tid('offer')).should('not.exist');
});
it('tells the rider when every driver passes', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.pass('maria');
cy.pass('james');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'No drivers available');
cy.waitForDispatch((s) => s.rides[0]?.status === 'unmatched', 'ride unmatched');
// Going back lets the rider try again with a fresh auction.
cy.phone('alice').find(tid('unmatched-back')).click();
cy.phone('alice').find(tid('confirm-ride')).click();
cy.phone('maria').find(tid('offer')).should('have.length', 1);
cy.waitForDispatch((s) => s.rides.length === 2 && s.rides[1].status === 'auction', 'second auction open');
});
it('withdraws the offer from every driver when the rider cancels', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.bid('maria', 9);
cy.phone('alice').find(tid('cancel-request')).click();
cy.phone('alice').find(tid('confirm-ride')).should('exist');
for (const driver of ['maria', 'james']) {
cy.phone(driver).find(tid('offer')).should('not.exist');
cy.phone(driver).find(tid('driver-result')).should('have.text', 'The rider cancelled their request');
}
cy.waitForDispatch((s) => s.rides[0]?.status === 'cancelled', 'ride cancelled');
});
it('never gives one driver two rides at once', () => {
cy.resetDispatch();
cy.openPhones([alice, bob, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.requestRide('bob', 'ferry', 'Ferry Building');
cy.phone('maria').find(tid('offer')).should('have.length', 2);
// Maria is the cheapest on both rides.
cy.bid('maria', 7, { rider: 'Alice' });
cy.bid('maria', 6, { rider: 'Bob' });
cy.bid('james', 9, { rider: 'Bob' });
// Bob's auction has every answer, so it closes first and Maria wins it.
cy.phone('bob').find(tid('driver-name')).should('contain.text', 'Maria');
cy.phone('bob').find(tid('ride-fare')).should('have.text', '$6.00');
// Alice's auction is still waiting on James. Maria's lower bid no longer counts.
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver');
cy.bid('james', 10, { rider: 'Alice' });
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.00');
cy.waitForDispatch((s) => s.rides.every((r) => r.status === 'matched'), 'both rides matched').then(({ rides }) => {
expect(rides.map((r) => [r.riderId, r.winnerId])).to.deep.equal([
['alice', 'james'],
['bob', 'maria'],
]);
});
});
});

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/bid-capped-at-quote';
it(scenario.title, scenario.run);

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/every-driver-passes';
it(scenario.title, scenario.run);

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/lowest-bid-wins';
it(scenario.title, scenario.run);

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/one-ride-per-driver';
it(scenario.title, scenario.run);

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/rider-cancels';
it(scenario.title, scenario.run);

View file

@ -0,0 +1,3 @@
import scenario from '../scenarios/tie-goes-to-earlier-bid';
it(scenario.title, scenario.run);

View file

@ -0,0 +1,17 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria, james } from '../support/phones';
export default defineScenario('closes on the deadline and awards whoever did bid', () => {
cy.resetDispatch({ auctionDurationMs: 4000 });
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.bid('maria', 10.75);
// James never answers; the auction stays open until the window runs out.
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver');
cy.phone('alice').find(tid('ride-status'), { timeout: 8000 }).should('have.text', 'Driver found');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.75');
cy.phone('james').find(tid('driver-result')).should('have.text', 'That request closed before you answered');
cy.phone('james').find(tid('offer')).should('not.exist');
});

View file

@ -0,0 +1,14 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria } from '../support/phones';
export default defineScenario('does not let a driver bid above the quoted fare', () => {
cy.resetDispatch();
cy.openPhones([alice, maria]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.phone('maria').find(tid('bid-input')).clear().type('11.01');
cy.phone('maria').find(tid('submit-bid')).should('have.attr', 'aria-disabled', 'true');
cy.phone('maria').find(tid('bid-input')).clear().type('11');
cy.phone('maria').find(tid('submit-bid')).should('not.have.attr', 'aria-disabled');
});

View file

@ -0,0 +1,20 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria, james } from '../support/phones';
export default defineScenario('tells the rider when every driver passes', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.pass('maria');
cy.pass('james');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'No drivers available');
cy.waitForDispatch((s) => s.rides[0]?.status === 'unmatched', 'ride unmatched');
// Going back lets the rider try again with a fresh auction.
cy.phone('alice').find(tid('unmatched-back')).click();
cy.phone('alice').find(tid('confirm-ride')).click();
cy.phone('maria').find(tid('offer')).should('have.length', 1);
cy.waitForDispatch((s) => s.rides.length === 2 && s.rides[1].status === 'auction', 'second auction open');
});

View file

@ -0,0 +1,50 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria, james, aisha } from '../support/phones';
export default defineScenario('awards the ride to the lowest bid and runs the trip end to end', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james, aisha]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
// Every driver is invited, capped at the quoted fare.
for (const driver of ['maria', 'james', 'aisha']) {
cy.phone(driver).find(tid('offer-destination')).should('have.text', 'Alice → Ferry Building');
cy.phone(driver).find(tid('offer-max-fare')).should('have.text', 'Rider pays up to $11.00');
}
cy.bid('maria', 10);
cy.phone('maria').find(tid('offer-my-bid')).should('contain.text', 'You bid $10.00');
cy.phone('alice').find(tid('offer-count')).should('have.text', '1 offer so far');
cy.bid('james', 8.5);
cy.phone('alice').find(tid('offer-count')).should('have.text', '2 offers so far');
// The last driver answering closes the auction without waiting out the clock.
cy.pass('aisha');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Driver found');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$8.50');
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
cy.phone('alice').find(tid('matched-driver')).should('contain.text', '8TRW115');
cy.phone('james').find(tid('job-fare')).should('have.text', 'Fare $8.50');
cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride');
cy.phone('aisha').find(tid('driver-idle')).should('exist');
cy.phone('aisha').find(tid('driver-result')).should('not.exist');
cy.dispatchState().then(({ rides }) => {
expect(rides).to.have.length(1);
expect(rides[0]).to.include({ status: 'matched', winnerId: 'james', fare: 8.5, maxFare: 11 });
expect(rides[0].declined).to.deep.equal(['aisha']);
});
// The winning driver finishes the trip; the rider's phone follows.
cy.phone('james').find(tid('complete-trip')).click();
cy.phone('james').find(tid('driver-result')).should('have.text', 'Trip complete · you earned $8.50');
cy.phone('alice').find(tid('ride-status')).should('have.text', 'You’ve arrived');
cy.phone('alice').should('contain.text', 'Thanks for riding with James.');
cy.phone('alice').find(tid('trip-done')).click();
cy.phone('alice').find(tid('where-to')).should('exist');
});

View file

@ -0,0 +1,33 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, bob, maria, james } from '../support/phones';
export default defineScenario('never gives one driver two rides at once', () => {
cy.resetDispatch();
cy.openPhones([alice, bob, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.requestRide('bob', 'ferry', 'Ferry Building');
cy.phone('maria').find(tid('offer')).should('have.length', 2);
// Maria is the cheapest on both rides.
cy.bid('maria', 7, { rider: 'Alice' });
cy.bid('maria', 6, { rider: 'Bob' });
cy.bid('james', 9, { rider: 'Bob' });
// Bob's auction has every answer, so it closes first and Maria wins it.
cy.phone('bob').find(tid('driver-name')).should('contain.text', 'Maria');
cy.phone('bob').find(tid('ride-fare')).should('have.text', '$6.00');
// Alice's auction is still waiting on James. Maria's lower bid no longer counts.
cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver');
cy.bid('james', 10, { rider: 'Alice' });
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.00');
cy.waitForDispatch((s) => s.rides.every((r) => r.status === 'matched'), 'both rides matched').then(({ rides }) => {
expect(rides.map((r) => [r.riderId, r.winnerId])).to.deep.equal([
['alice', 'james'],
['bob', 'maria'],
]);
});
});

View file

@ -0,0 +1,18 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria, james } from '../support/phones';
export default defineScenario('withdraws the offer from every driver when the rider cancels', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.bid('maria', 9);
cy.phone('alice').find(tid('cancel-request')).click();
cy.phone('alice').find(tid('confirm-ride')).should('exist');
for (const driver of ['maria', 'james']) {
cy.phone(driver).find(tid('offer')).should('not.exist');
cy.phone(driver).find(tid('driver-result')).should('have.text', 'The rider cancelled their request');
}
cy.waitForDispatch((s) => s.rides[0]?.status === 'cancelled', 'ride cancelled');
});

View file

@ -0,0 +1,15 @@
import { defineScenario, tid } from '../support/e2e';
import { alice, maria, james } from '../support/phones';
export default defineScenario('breaks a tie in favour of the earlier bid', () => {
cy.resetDispatch();
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
cy.bid('james', 9);
cy.phone('james').find(tid('offer-my-bid')).should('exist');
cy.bid('maria', 9);
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride');
});

View file

@ -14,6 +14,14 @@ export type PhoneSpec = {
rating?: number;
};
/**
* 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 = {
config: { auctionDurationMs: number };
devices: (PhoneSpec & { online: boolean; busy: boolean })[];

View file

@ -0,0 +1,8 @@
import type { PhoneSpec } from './e2e';
// The cast shared by the auction scenarios.
export const alice: PhoneSpec = { role: 'rider', id: 'alice', name: 'Alice' };
export const bob: PhoneSpec = { role: 'rider', id: 'bob', name: 'Bob' };
export const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', car: 'Toyota Camry · Silver', plate: '7KDL294', rating: 4.95 };
export const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James', car: 'Honda Accord · Black', plate: '8TRW115', rating: 4.88 };
export const aisha: PhoneSpec = { role: 'driver', id: 'aisha', name: 'Aisha', car: 'Tesla Model 3 · White', plate: '9EVA662', rating: 4.97 };

View file

@ -22,6 +22,8 @@ npm run e2e # start dispatch + web server, run every scenario headlessly,
npm run e2e:open # same, but in the Cypress UI so you can watch the phones
```
In the Cypress UI, each scenario is its own spec; pick one to watch it, or use **Run all specs** to play them back to back. To run one scenario headlessly, start the servers (below) and run `npx cypress run --spec cypress/e2e/lowest-bid-wins.cy.ts`.
To explore by hand, start the two servers yourself:
```bash
@ -71,8 +73,22 @@ What each driver sees afterwards:
| `src/app/driver.tsx` | Driver screen: offers, bid input, current job, result banner. |
| `src/components/RideMap.tsx` / `.web.tsx` | Native map, and a plain backdrop on web (`react-native-maps` has no web build). |
| `public/sim.html` | The simulator page: phones as iframes plus the live dispatch panel. |
| `cypress/support/e2e.ts` | Custom commands and network fixtures. |
| `cypress/e2e/auction.cy.ts` | Auction scenarios. |
| `cypress/support/e2e.ts` | Custom commands, network fixtures, and `defineScenario`. |
| `cypress/support/phones.ts` | The shared cast: riders Alice and Bob; drivers Maria, James and Aisha. |
| `cypress/scenarios/<name>.ts` | One scenario's steps and assertions. |
| `cypress/e2e/<name>.cy.ts` | A thin spec that registers one scenario. |
### Scenarios
| Spec | Checks |
| --------------------------- | ------------------------------------------------------------------------------ |
| `lowest-bid-wins` | Lowest bid wins, the losers are told, and the trip runs to "You've arrived". |
| `tie-goes-to-earlier-bid` | Equal bids go to whoever bid first. |
| `bid-capped-at-quote` | A bid above the rider's quoted fare can't be submitted. |
| `auction-deadline` | The auction closes on its deadline and a driver who never answered is told. |
| `every-driver-passes` | The rider sees "No drivers available" and can request again. |
| `rider-cancels` | Cancelling withdraws the offer from every driver. |
| `one-ride-per-driver` | A driver who wins one ride isn't awarded another at the same time. |
### Protocol
@ -111,14 +127,14 @@ Environment variables: `PORT` (default 8787) and `AUCTION_MS` (default auction w
## Writing scenarios
A scenario's steps live in `cypress/scenarios/`, apart from the spec file that runs them, so the same steps can back more than one spec. Add both files:
```ts
import { tid, type PhoneSpec } from '../support/e2e';
// cypress/scenarios/lowest-bid-wins.ts
import { defineScenario, tid } from '../support/e2e';
import { alice, james, maria } from '../support/phones';
const alice: PhoneSpec = { role: 'rider', id: 'alice', name: 'Alice' };
const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', plate: '7KDL294' };
const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James' };
it('lowest bid wins', () => {
export default defineScenario('lowest bid wins', () => {
cy.resetDispatch(); // always first: dispatch state outlives page loads
cy.openPhones([alice, maria, james]); // waits until every phone is rendered and connected
@ -132,6 +148,15 @@ it('lowest bid wins', () => {
});
```
```ts
// cypress/e2e/lowest-bid-wins.cy.ts
import scenario from '../scenarios/lowest-bid-wins';
it(scenario.title, scenario.run);
```
Each scenario starts with `cy.resetDispatch()` and opens its own phones, so any spec can run alone or in any order.
### Commands
| Command | Does |