Add slow versions of every auction scenario

Each scenario gets a spec in cypress/e2e/slow/ that replays the same
steps for watching: every phone step pauses and outlines the acting
phone, typing is visible, time windows stretch 4x, and the final
screen holds for 5 s. npm run e2e:slow runs them in a visible Chrome
window; npm run e2e still runs only the normal-speed specs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-09-30 06:49:58 -08:00
parent 3a9944489f
commit 8d8b6727c7
21 changed files with 138 additions and 19 deletions

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/auction-deadline';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/bid-capped-at-quote';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/every-driver-passes';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/lowest-bid-wins';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/one-ride-per-driver';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/rider-cancels';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/auction-deadline';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/bid-capped-at-quote';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/every-driver-passes';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/lowest-bid-wins';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/one-ride-per-driver';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/rider-cancels';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -0,0 +1,4 @@
import scenario from '../../scenarios/tie-goes-to-earlier-bid';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');

View file

@ -1,3 +1,4 @@
import scenario from '../scenarios/tie-goes-to-earlier-bid';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);

View file

@ -1,8 +1,9 @@
import { defineScenario, tid } from '../support/e2e';
import { scaled } from '../support/pace';
import { alice, maria, james } from '../support/phones';
export default defineScenario('closes on the deadline and awards whoever did bid', () => {
cy.resetDispatch({ auctionDurationMs: 4000 });
cy.resetDispatch({ auctionDurationMs: scaled(4000) });
cy.openPhones([alice, maria, james]);
cy.requestRide('alice', 'ferry', 'Ferry Building');
@ -10,7 +11,7 @@ export default defineScenario('closes on the deadline and awards whoever did bid
// 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-status'), { timeout: scaled(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

@ -1,4 +1,5 @@
import { defineScenario, tid } from '../support/e2e';
import { typeDelayMs } from '../support/pace';
import { alice, maria } from '../support/phones';
export default defineScenario('does not let a driver bid above the quoted fare', () => {
@ -6,9 +7,9 @@ export default defineScenario('does not let a driver bid above the quoted fare',
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('bid-input')).clear().type('11.01', { delay: typeDelayMs() });
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('bid-input')).clear().type('11', { delay: typeDelayMs() });
cy.phone('maria').find(tid('submit-bid')).should('not.have.attr', 'aria-disabled');
});

View file

@ -63,13 +63,19 @@ declare global {
}
}
import { isSlow, 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 dispatch server's default auction window (AUCTION_MS); slow runs stretch it.
const DEFAULT_AUCTION_MS = 15_000;
Cypress.Commands.add('resetDispatch', (options = {}) => {
return cy.request('POST', `${dispatchUrl()}/test/reset`, options).its('body');
const auctionDurationMs = options.auctionDurationMs ?? (isSlow() ? scaled(DEFAULT_AUCTION_MS) : undefined);
return cy.request('POST', `${dispatchUrl()}/test/reset`, { auctionDurationMs }).its('body');
});
Cypress.Commands.add('dispatchState', () => {
@ -113,6 +119,13 @@ Cypress.Commands.add('openPhones', (phones) => {
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 })
@ -121,7 +134,7 @@ Cypress.Commands.add('phone', (id) => {
Cypress.Commands.add('requestRide', (riderId, query, placeTitle) => {
cy.phone(riderId).find(tid('where-to')).click();
cy.phone(riderId).find(tid('destination-input')).type(query);
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');
@ -133,7 +146,7 @@ function offerOn(driverId: string, rider?: string) {
}
Cypress.Commands.add('bid', (driverId, amount, options = {}) => {
offerOn(driverId, options.rider).find(tid('bid-input')).clear().type(amount.toFixed(2));
offerOn(driverId, options.rider).find(tid('bid-input')).clear().type(amount.toFixed(2), { delay: typeDelayMs() });
offerOn(driverId, options.rider).find(tid('submit-bid')).click();
});

35
cypress/support/pace.ts Normal file
View file

@ -0,0 +1,35 @@
// How fast a scenario plays. Slow runs are for watching: every step on a phone pauses
// first and highlights that phone, typing is visible, and time windows stretch so the
// scenario still holds at that speed.
import type { Scenario } from './e2e';
export type Pace = 'normal' | 'slow';
const PACES = {
normal: { stepDelayMs: 0, typeDelayMs: 10, timeScale: 1, endPauseMs: 0 },
// endPauseMs holds the final screen before the next spec loads or the window closes.
slow: { stepDelayMs: 800, typeDelayMs: 150, timeScale: 4, endPauseMs: 5000 },
} satisfies Record<Pace, { stepDelayMs: number; typeDelayMs: number; timeScale: number; endPauseMs: number }>;
let current: Pace = 'normal';
export const isSlow = () => current === 'slow';
export const stepDelayMs = () => PACES[current].stepDelayMs;
export const typeDelayMs = () => PACES[current].typeDelayMs;
/** Stretch a duration (auction window, assertion timeout) to fit the current pace. */
export const scaled = (ms: number) => ms * PACES[current].timeScale;
/**
* Register a scenario as a test at the given pace. The pace is set when the test runs,
* not when it's registered, because "Run all specs" loads every spec into one page.
*/
export function runScenario(scenario: Scenario, pace: Pace = 'normal') {
it(pace === 'slow' ? `${scenario.title} (slow)` : scenario.title, () => {
current = pace;
scenario.run();
const { endPauseMs } = PACES[pace];
if (endPauseMs > 0) cy.wait(endPauseMs, { log: false });
});
}

View file

@ -20,9 +20,10 @@ This setup runs several "phones" at once in one browser, without iOS simulators,
```bash
npm run e2e # start dispatch + web server, run every scenario headlessly, shut down
npm run e2e:open # same, but in the Cypress UI so you can watch the phones
npm run e2e:slow # every scenario at watching speed, in a visible Chrome window
```
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`.
In the Cypress UI, each scenario is its own spec, and `slow/` holds a slow version of each. Pick one to watch it, or use **Run all specs** to play them back to back. `npm run e2e` runs only the normal-speed specs. 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:
@ -76,7 +77,9 @@ What each driver sees afterwards:
| `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. |
| `cypress/e2e/<name>.cy.ts` | A thin spec that registers one scenario at normal speed. |
| `cypress/e2e/slow/<name>.cy.ts` | The same scenario at watching speed. |
| `cypress/support/pace.ts` | `runScenario`, pace settings, and `scaled()` for time windows. |
### Scenarios
@ -151,8 +154,17 @@ export default defineScenario('lowest bid wins', () => {
```ts
// cypress/e2e/lowest-bid-wins.cy.ts
import scenario from '../scenarios/lowest-bid-wins';
import { runScenario } from '../support/pace';
it(scenario.title, scenario.run);
runScenario(scenario);
```
```ts
// cypress/e2e/slow/lowest-bid-wins.cy.ts
import scenario from '../../scenarios/lowest-bid-wins';
import { runScenario } from '../../support/pace';
runScenario(scenario, 'slow');
```
Each scenario starts with `cy.resetDispatch()` and opens its own phones, so any spec can run alone or in any order.
@ -191,6 +203,24 @@ Place search (Photon) and routing (OSRM) are external services, so every test st
So the quoted fares are: UberX **$11.00**, Comfort $14.00, UberXL $17.50, Black $26.00. To test other fares, add a fixture and override the intercept inside the test.
### Slow runs
A slow spec plays the same steps for a human to follow:
- Every `cy.phone(...)` step pauses first (800 ms) and outlines that phone in blue in the simulator.
- Typing is visible (150 ms per key).
- Each scenario holds its final screen for 5 s before the next spec loads or the window closes.
- Time windows are stretched 4×. `cy.resetDispatch()` without an explicit window gets 60 s instead of 15 s.
Tune these in `PACES` in `cypress/support/pace.ts`. If a scenario depends on a time window, wrap the numbers in `scaled()` so it still holds when slow:
```ts
cy.resetDispatch({ auctionDurationMs: scaled(4000) });
cy.phone('alice').find(tid('ride-status'), { timeout: scaled(8000) }).should('have.text', 'Driver found');
```
Use `typeDelayMs()` for any `.type()` a scenario does directly. The pace is set when each test starts, so normal and slow specs can share one "Run all specs" session.
### Timing tips
- Prefer letting every invited driver answer; the auction then closes at once, with no waiting.

View file

@ -39,8 +39,9 @@
"lint": "expo lint",
"dispatch": "node server/dispatch.mts",
"sim:web": "expo start --port 8090",
"e2e": "start-server-and-test dispatch http-get://localhost:8787/state sim:web http://localhost:8090 'cypress run'",
"e2e:open": "start-server-and-test dispatch http-get://localhost:8787/state sim:web http://localhost:8090 'cypress open --e2e'"
"e2e": "start-server-and-test dispatch http-get://localhost:8787/state sim:web http://localhost:8090 'cypress run --spec \"cypress/e2e/*.cy.ts\"'",
"e2e:open": "start-server-and-test dispatch http-get://localhost:8787/state sim:web http://localhost:8090 'cypress open --e2e'",
"e2e:slow": "start-server-and-test dispatch http-get://localhost:8787/state sim:web http://localhost:8090 'cypress run --headed --browser chrome --spec \"cypress/e2e/slow/*.cy.ts\"'"
},
"private": true
}

View file

@ -51,7 +51,10 @@
border: 10px solid #111;
border-radius: 36px;
background: #fff;
transition: border-color 0.2s;
}
/* Slow test runs mark the phone that's about to act. */
.phone iframe.acting { border-color: #2563eb; }
aside {
flex: none;
width: 320px;