diff --git a/cypress/e2e/auction-deadline.cy.ts b/cypress/e2e/auction-deadline.cy.ts index e55bbcb..b2f4a47 100644 --- a/cypress/e2e/auction-deadline.cy.ts +++ b/cypress/e2e/auction-deadline.cy.ts @@ -1,3 +1,4 @@ import scenario from '../scenarios/auction-deadline'; +import { runScenario } from '../support/pace'; -it(scenario.title, scenario.run); +runScenario(scenario); diff --git a/cypress/e2e/bid-capped-at-quote.cy.ts b/cypress/e2e/bid-capped-at-quote.cy.ts index 94103e6..6bae92f 100644 --- a/cypress/e2e/bid-capped-at-quote.cy.ts +++ b/cypress/e2e/bid-capped-at-quote.cy.ts @@ -1,3 +1,4 @@ import scenario from '../scenarios/bid-capped-at-quote'; +import { runScenario } from '../support/pace'; -it(scenario.title, scenario.run); +runScenario(scenario); diff --git a/cypress/e2e/every-driver-passes.cy.ts b/cypress/e2e/every-driver-passes.cy.ts index 3f6ffea..8000220 100644 --- a/cypress/e2e/every-driver-passes.cy.ts +++ b/cypress/e2e/every-driver-passes.cy.ts @@ -1,3 +1,4 @@ import scenario from '../scenarios/every-driver-passes'; +import { runScenario } from '../support/pace'; -it(scenario.title, scenario.run); +runScenario(scenario); diff --git a/cypress/e2e/lowest-bid-wins.cy.ts b/cypress/e2e/lowest-bid-wins.cy.ts index eeb79bb..fded4fc 100644 --- a/cypress/e2e/lowest-bid-wins.cy.ts +++ b/cypress/e2e/lowest-bid-wins.cy.ts @@ -1,3 +1,4 @@ import scenario from '../scenarios/lowest-bid-wins'; +import { runScenario } from '../support/pace'; -it(scenario.title, scenario.run); +runScenario(scenario); diff --git a/cypress/e2e/one-ride-per-driver.cy.ts b/cypress/e2e/one-ride-per-driver.cy.ts index 9aa3174..c804f73 100644 --- a/cypress/e2e/one-ride-per-driver.cy.ts +++ b/cypress/e2e/one-ride-per-driver.cy.ts @@ -1,3 +1,4 @@ import scenario from '../scenarios/one-ride-per-driver'; +import { runScenario } from '../support/pace'; -it(scenario.title, scenario.run); +runScenario(scenario); diff --git a/cypress/e2e/rider-cancels.cy.ts b/cypress/e2e/rider-cancels.cy.ts index 921944b..b6f1e3a 100644 --- a/cypress/e2e/rider-cancels.cy.ts +++ b/cypress/e2e/rider-cancels.cy.ts @@ -1,3 +1,4 @@ import scenario from '../scenarios/rider-cancels'; +import { runScenario } from '../support/pace'; -it(scenario.title, scenario.run); +runScenario(scenario); diff --git a/cypress/e2e/slow/auction-deadline.cy.ts b/cypress/e2e/slow/auction-deadline.cy.ts new file mode 100644 index 0000000..4a31f62 --- /dev/null +++ b/cypress/e2e/slow/auction-deadline.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/auction-deadline'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/bid-capped-at-quote.cy.ts b/cypress/e2e/slow/bid-capped-at-quote.cy.ts new file mode 100644 index 0000000..1422f03 --- /dev/null +++ b/cypress/e2e/slow/bid-capped-at-quote.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/bid-capped-at-quote'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/every-driver-passes.cy.ts b/cypress/e2e/slow/every-driver-passes.cy.ts new file mode 100644 index 0000000..c9f564f --- /dev/null +++ b/cypress/e2e/slow/every-driver-passes.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/every-driver-passes'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/lowest-bid-wins.cy.ts b/cypress/e2e/slow/lowest-bid-wins.cy.ts new file mode 100644 index 0000000..0f1f6a4 --- /dev/null +++ b/cypress/e2e/slow/lowest-bid-wins.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/lowest-bid-wins'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/one-ride-per-driver.cy.ts b/cypress/e2e/slow/one-ride-per-driver.cy.ts new file mode 100644 index 0000000..58f7f15 --- /dev/null +++ b/cypress/e2e/slow/one-ride-per-driver.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/one-ride-per-driver'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/rider-cancels.cy.ts b/cypress/e2e/slow/rider-cancels.cy.ts new file mode 100644 index 0000000..0bccf34 --- /dev/null +++ b/cypress/e2e/slow/rider-cancels.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/rider-cancels'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/slow/tie-goes-to-earlier-bid.cy.ts b/cypress/e2e/slow/tie-goes-to-earlier-bid.cy.ts new file mode 100644 index 0000000..b1ac949 --- /dev/null +++ b/cypress/e2e/slow/tie-goes-to-earlier-bid.cy.ts @@ -0,0 +1,4 @@ +import scenario from '../../scenarios/tie-goes-to-earlier-bid'; +import { runScenario } from '../../support/pace'; + +runScenario(scenario, 'slow'); diff --git a/cypress/e2e/tie-goes-to-earlier-bid.cy.ts b/cypress/e2e/tie-goes-to-earlier-bid.cy.ts index 5c05047..b5d01f0 100644 --- a/cypress/e2e/tie-goes-to-earlier-bid.cy.ts +++ b/cypress/e2e/tie-goes-to-earlier-bid.cy.ts @@ -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); diff --git a/cypress/scenarios/auction-deadline.ts b/cypress/scenarios/auction-deadline.ts index dddeb8e..3ef4e48 100644 --- a/cypress/scenarios/auction-deadline.ts +++ b/cypress/scenarios/auction-deadline.ts @@ -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'); diff --git a/cypress/scenarios/bid-capped-at-quote.ts b/cypress/scenarios/bid-capped-at-quote.ts index 89034f3..a27ae18 100644 --- a/cypress/scenarios/bid-capped-at-quote.ts +++ b/cypress/scenarios/bid-capped-at-quote.ts @@ -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'); }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 099e9c6..d231fa8 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -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(); }); diff --git a/cypress/support/pace.ts b/cypress/support/pace.ts new file mode 100644 index 0000000..df39f40 --- /dev/null +++ b/cypress/support/pace.ts @@ -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; + +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 }); + }); +} diff --git a/docs/multi-phone-simulator.md b/docs/multi-phone-simulator.md index 75fae4e..18caefe 100644 --- a/docs/multi-phone-simulator.md +++ b/docs/multi-phone-simulator.md @@ -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/.ts` | One scenario's steps and assertions. | -| `cypress/e2e/.cy.ts` | A thin spec that registers one scenario. | +| `cypress/e2e/.cy.ts` | A thin spec that registers one scenario at normal speed. | +| `cypress/e2e/slow/.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. diff --git a/package.json b/package.json index f05badf..d6bf976 100644 --- a/package.json +++ b/package.json @@ -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 } diff --git a/public/sim.html b/public/sim.html index e60611b..dc64663 100644 --- a/public/sim.html +++ b/public/sim.html @@ -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;