The project currently has no test infrastructure — no test runner, no test scripts, no fixtures. This issue tracks introducing one, with the Strava boundary mocked so the whole stack can run offline and in CI.
Approach: a fake Strava server behind two env vars
Strava is reached from exactly three places, all in server/strava/raw-api.ts:
| Line |
URL |
| 84 |
https://www.strava.com/oauth/token |
| 189 |
https://www.strava.com/api/v3 |
| 213 |
http://www.strava.com/oauth/authorize |
Hoisting these to STRAVA_API_BASE / STRAVA_OAUTH_BASE (defaulting to the real values, wired through shared/config/dotenv.js like every other setting) is enough to cover both halves of the integration:
- the server's own outbound token exchange and API calls;
- the OAuth popup — because the authorize URL is built server-side at
raw-api.ts:213 and handed to the browser over the websocket, pointing STRAVA_OAUTH_BASE at the fake server means window.open lands there naturally. The fake server 302s straight to ${SERVER_DOMAIN}/api/token?code&state&scope, closing the loop with no interception anywhere.
redirect_uri is derived from SERVER_DOMAIN and is unaffected.
The fake server needs six endpoints: POST /oauth/token, GET /oauth/authorize (instant 302), GET /athlete, GET /athlete/activities, GET /athletes/:id/routes, GET /gear/:id. The pagination loops in server/strava/index.ts terminate when a page returns fewer than 200 items, so a single short page ends them.
Alternatives considered
- MSW in the server process — works without the env vars (it patches
http.ClientRequest, which node-fetch v2 sits on) and gives nice per-test error injection, but it has to be preloaded into the server process for e2e, and it can't touch the OAuth popup: that's a top-level cross-origin navigation, which a service worker structurally cannot intercept.
- Playwright's
context.route alone — fine for the popup, but it only sees browser-originated requests, so the server's own Strava calls are invisible to it. The two halves are coupled: fulfilling the popup with a fake code while the server still talks to real Strava fails the exchange and trips the process.exit(1) below. Half-mocking is worse than either extreme.
- Both remain useful in narrow spots — see the optional task at the end.
Fix first
These will actively obstruct testing:
Test layers
1. Pure functions (Vitest, no mocking). Highest value per unit of effort.
2. Server integration (in-process app + fake Strava). No browser, millisecond-scale.
3. Playwright e2e. Run against the built client served by Express on a single port, rather than the Vite dev server — production-shaped, and it avoids the :8081 client / :8080 SERVER_DOMAIN split where the popup redirect bypasses the Vite proxy.
Infrastructure
Optional
Suggested order
- Fix
process.exit(1) and split app.listen.
- Vitest + the pure-function tests (no mocking needed, immediate value).
- Hoist the Strava URLs, build the fake server, land the
.ics integration test as the first slice that proves the seam.
- Websocket integration tests.
- Playwright.
The project currently has no test infrastructure — no test runner, no test scripts, no fixtures. This issue tracks introducing one, with the Strava boundary mocked so the whole stack can run offline and in CI.
Approach: a fake Strava server behind two env vars
Strava is reached from exactly three places, all in
server/strava/raw-api.ts:https://www.strava.com/oauth/tokenhttps://www.strava.com/api/v3http://www.strava.com/oauth/authorizeHoisting these to
STRAVA_API_BASE/STRAVA_OAUTH_BASE(defaulting to the real values, wired throughshared/config/dotenv.jslike every other setting) is enough to cover both halves of the integration:raw-api.ts:213and handed to the browser over the websocket, pointingSTRAVA_OAUTH_BASEat the fake server meanswindow.openlands there naturally. The fake server 302s straight to${SERVER_DOMAIN}/api/token?code&state&scope, closing the loop with no interception anywhere.redirect_uriis derived fromSERVER_DOMAINand is unaffected.The fake server needs six endpoints:
POST /oauth/token,GET /oauth/authorize(instant 302),GET /athlete,GET /athlete/activities,GET /athletes/:id/routes,GET /gear/:id. The pagination loops inserver/strava/index.tsterminate when a page returns fewer than 200 items, so a single short page ends them.Alternatives considered
http.ClientRequest, whichnode-fetchv2 sits on) and gives nice per-test error injection, but it has to be preloaded into the server process for e2e, and it can't touch the OAuth popup: that's a top-level cross-origin navigation, which a service worker structurally cannot intercept.context.routealone — fine for the popup, but it only sees browser-originated requests, so the server's own Strava calls are invisible to it. The two halves are coupled: fulfilling the popup with a fakecodewhile the server still talks to real Strava fails the exchange and trips theprocess.exit(1)below. Half-mocking is worse than either extreme.Fix first
These will actively obstruct testing:
process.exit(1)on a failed token exchange (raw-api.ts:96-100, already carries aTODO: why such a hard exit?). Any auth-failure test takes down the server and every test after it.app.listenat module scope (server/app.ts:45). Split it soapp.tsexports the app and a separate entrypoint listens, enabling in-process integration tests.SESSIONS_DIR = 'sessions'(raw-api.ts:15) and'static/auth.html'(server/routes/token.ts:10) resolve againstprocess.cwd(). Either run each test server in a temp cwd or make the sessions dir configurable — needed for state isolation between tests.await new Promise(() => undefined)(raw-api.ts:222) never resolves by design, andCALLBACK_TIMEOUTis 15 min (server/strava/token.ts:11). A broken test hangs instead of failing; make the timeout configurable and set aggressive per-test timeouts.Test layers
1. Pure functions (Vitest, no mocking). Highest value per unit of effort.
server/routes/activities.ts:30-111(convertActivitySummary,convertRouteSummary,convertGear). Noteactivities.ts:71has a known-wrong elevationlosscalculation flagged with aTODO— pin the intended behaviour with a test.d6a51be Fix timezones. Coversactivity.timezone.split(' ')[1]inserver/calendar.ts:57and the local/UTC date pairing inconvertActivitySummary.toHmsand the description builder out of the.icsroute handler and test them directly.TimeRange.cap, and the client utils (midpoint,stats,numberFormat,groupMapItems).2. Server integration (in-process app + fake Strava). No browser, millisecond-scale.
.icsoutput fromGET /calendar/:token.ics— likely the single highest-payoff test in the repo.GET /api/user,DELETE /api/user./api/activities, driven with a plainwsclient: assert thehandshake→stats→activities→gearsequence.raw-api.ts:294-296.3. Playwright e2e. Run against the built client served by Express on a single port, rather than the Vite dev server — production-shaped, and it avoids the
:8081client /:8080SERVER_DOMAINsplit where the popup redirect bypasses the Vite proxy.window.open(client/src/stores/ContinueLoginStore.ts:24) resolved viapostMessagefromserver/static/auth.html, so usecontext.waitForEvent('page').api.mapbox.comwithcontext.route— the map needs a real token and fetches tiles. Assert on sidebar DOM state, not the canvas.client/src/utils/storage.tsdon't leak across tests.Infrastructure
vitesttoshared/server/clientand@playwright/testat the root, with atestscript alongside the existinglint/prettierones.server/strava/model.ts..env.test—DOTENV_FILEis already supported byshared/config/dotenv.js, so no new plumbing is needed. SetVALIDATE_USER_BEFORE_CACHEexplicitly here.Optional
client/src/stores/ActivityStore.ts:396-445with synthetic frames and no server at all. That switch pluscheckFinishedis the densest logic in the client, and reaching it through Playwright is slow and brittle. Nice to have, not load-bearing.Suggested order
process.exit(1)and splitapp.listen..icsintegration test as the first slice that proves the seam.