diff --git a/.github/workflows/next-branch-ci.yml b/.github/workflows/next-branch-ci.yml index a1cca140..e789d038 100644 --- a/.github/workflows/next-branch-ci.yml +++ b/.github/workflows/next-branch-ci.yml @@ -72,6 +72,13 @@ jobs: env: CI: true E2E_SHARDS_CONFIG: playwright/playwright.config.next.ts + # The auth-related specs call `authService.getClientId()` through + # `.auth-button--signin`; Vite reads VITE_* at build time. The main + # workflow (playwright-e2e-clean.yml) uses the same placeholder. + VITE_INTERVALS_CLIENT_ID: playwright-e2e-client + # SwiftShader fallback for WebGL contexts the UI still mounts (e.g. the + # route detail map's WebGL leaflet / MapLibre layer). + LIBGL_ALWAYS_SOFTWARE: '1' timeout-minutes: 20 steps: - uses: actions/checkout@v7 diff --git a/playwright/playwright.config.next.ts b/playwright/playwright.config.next.ts index 6ab3f558..271022a1 100644 --- a/playwright/playwright.config.next.ts +++ b/playwright/playwright.config.next.ts @@ -70,36 +70,10 @@ const EXTRA_IGNORE = [ '**/kml-projection.spec.ts', '**/rownative-course-import.spec.ts', '**/rownative-track-geometry.spec.ts', - // Pre-existing Svelte-port gaps surfaced by this job's first runs (follow-up - // issues will land as AC1 UI rejoin progresses; the e2e-shards ignore list - // shrinks as each gap is fixed): - // - `auth.spec.ts` → `.auth-button--signin` does not drive the window to - // intervals.icu/oauth/authorize in the preview build. The Svelte - // AuthButton calls `auth.login() → authService.startLogin()`; the UI - // seam needs the real navigation (2 tests: redirect + PKCE params). - // - `intervals-login-failure.spec.ts` → the Svelte alert does not branch - // the `data-auth-error-class` by OAuth error (invalid_client, - // invalid_grant, 5xx, …) the way the React copy did; 6 AC tests fail - // reading the alert copy and `data-auth-error-class`. - // - `rownative-track-attach.spec.ts` → `window.__SELECTED_ROUTE.geometrySource` - // is not surfaced by the Svelte routes store, so the attach / remove - // verifications read `undefined` (2 tests). - // - `crash-detection.spec.ts` + `crash-telemetry.spec.ts` → both click the - // `.btn-try-demo` CTA to start a demo, which under SwiftShader swaps the - // stage for the "needs WebGPU" screen before the specs can read - // `.rower3d-canvas-container` and the run-start telemetry. The crash - // guard itself is unit-tested, but the e2e path through the demo depends - // on the engine running (4 tests). - // - `login-integration.spec.ts` → three happy-path OAuth callback flows - // time out waiting for `.auth-user-trigger` to become visible after the - // callback: the Svelte port isn't completing the token→profile handshake - // the way the React auth gate did (3 tests). - '**/auth.spec.ts', - '**/intervals-login-failure.spec.ts', - '**/rownative-track-attach.spec.ts', - '**/crash-detection.spec.ts', - '**/crash-telemetry.spec.ts', - '**/login-integration.spec.ts', + // The six Svelte-port gaps that the e2e-shards job surfaced on its first + // runs have been fixed in this PR — nothing from that batch remains here. + // Re-enabled: auth, intervals-login-failure, rownative-track-attach, + // crash-detection, crash-telemetry, login-integration. // Responsive matrix also drives the workout scene — stays off until the // engine runs on SwiftShader or we re-point its scene-alive check. // (The viewport projects only match `responsive.spec.ts`; keep them off diff --git a/src/__tests__/routesStore.svelte.test.ts b/src/__tests__/routesStore.svelte.test.ts index ac6d195e..85209b14 100644 --- a/src/__tests__/routesStore.svelte.test.ts +++ b/src/__tests__/routesStore.svelte.test.ts @@ -112,6 +112,57 @@ describe('RoutesStore', () => { await Promise.resolve(); expect(store.routeEnrichmentLoading['1']).toBe(false); }); + + // #542 AC2 — `rownative-track-attach.spec.ts` reads `window.__SELECTED_ROUTE` + // to verify a course's `geometrySource` after an attach / remove. App.tsx + // published that under `__PLAYWRIGHT_TESTING`; the Svelte port does it from + // the one place the selection is assigned. + it('publishes window.__SELECTED_ROUTE under __PLAYWRIGHT_TESTING on select and clear', () => { + const original = window.__PLAYWRIGHT_TESTING; + window.__PLAYWRIGHT_TESTING = true; + try { + const services = stubServices([ + route('1', { + distance: 20.5, + geometrySource: 'track', + externalDistanceMeters: 20500, + } as Partial), + ]); + const store = new RoutesStore(services); + store.load(); + + store.selectRoute(services.routeService.getRouteById('1')!); + expect(window.__SELECTED_ROUTE).toEqual({ + id: '1', + name: 'Route 1', + distanceKm: 20.5, + geometrySource: 'track', + externalDistanceMeters: 20500, + }); + + store.selectRoute(null as unknown as WaterRoute); + expect(window.__SELECTED_ROUTE).toBeUndefined(); + } finally { + window.__PLAYWRIGHT_TESTING = original; + window.__SELECTED_ROUTE = undefined; + } + }); + + it('does not touch window.__SELECTED_ROUTE outside __PLAYWRIGHT_TESTING', () => { + const original = window.__PLAYWRIGHT_TESTING; + window.__PLAYWRIGHT_TESTING = undefined; + window.__SELECTED_ROUTE = { id: 'untouched', name: '' } as unknown as typeof window.__SELECTED_ROUTE; + try { + const services = stubServices([route('1')]); + const store = new RoutesStore(services); + store.load(); + store.selectRoute(services.routeService.getRouteById('1')!); + expect(window.__SELECTED_ROUTE?.id).toBe('untouched'); + } finally { + window.__PLAYWRIGHT_TESTING = original; + window.__SELECTED_ROUTE = undefined; + } + }); }); // #542 P1: importFile dispatches on the file type (App.tsx handleRouteFileImport, diff --git a/src/lib/ui/routesStore.svelte.ts b/src/lib/ui/routesStore.svelte.ts index adfc155c..97c1b9af 100644 --- a/src/lib/ui/routesStore.svelte.ts +++ b/src/lib/ui/routesStore.svelte.ts @@ -159,6 +159,26 @@ export class RoutesStore { #select(route: WaterRoute | null | undefined): void { this.selectedRoute = route ?? null; if (route) this.#enrich(route); + this.#publishSelectedRouteForTests(); + } + + /** Playwright reads `window.__SELECTED_ROUTE` to verify an attach / remove + * (`rownative-track-attach.spec.ts`), the course badge (#206), and the + * card's distance against the engine's own total. App.tsx did this from a + * `useEffect([selectedRoute])`; the store does it from the one assignment + * point. Never set outside Playwright. */ + #publishSelectedRouteForTests(): void { + if (typeof window === 'undefined' || !window.__PLAYWRIGHT_TESTING) return; + const route = this.selectedRoute; + window.__SELECTED_ROUTE = route + ? { + id: route.id, + name: route.name, + distanceKm: route.distance, + geometrySource: route.geometrySource, + externalDistanceMeters: route.externalDistanceMeters, + } + : undefined; } #enrich(route: WaterRoute): void { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 92c3f4c0..16cc032a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -20,6 +20,9 @@ import { goto } from '$app/navigation'; import '../index.css'; import '../App.css'; + import { startTelemetry } from '$lib/utils/telemetryBoot'; + import { checkBootCrash, reportFromErrorBoundary } from '$lib/utils/crashBoot'; + import { crashReporterService } from '$lib/services/crashReporterService'; import { setServices } from '$lib/ui/servicesContext'; import { setAuth } from '$lib/ui/authContext'; import { AuthStore } from '$lib/ui/auth.svelte'; @@ -41,6 +44,28 @@ let { children } = $props(); + // Before any component effects run, so an error during the first mount is + // recorded too — matches the React main.tsx boot order. + if (typeof window !== 'undefined') { + startTelemetry(); + const BUILD_SHA = (import.meta.env.VITE_BUILD_SHA as string | undefined) ?? 'dev'; + checkBootCrash(crashReporterService, { buildSha: BUILD_SHA }); + // #473 AC2 test seam: an e2e spec forces an error-boundary trip via this + // hook; production boundaries make the same call from their own error path. + if (window.__PLAYWRIGHT_TESTING) { + (window as unknown as Record).__VIRTUALROW_SIMULATE_BOUNDARY__ = ( + message: string, + ): void => { + reportFromErrorBoundary( + crashReporterService, + new Error(message), + { componentStack: ' at AlwaysThrows\n at SceneErrorBoundary' }, + { boundary: 'Canvas3D', buildSha: BUILD_SHA }, + ); + }; + } + } + setServices(); const auth = setAuth(new AuthStore()); // The route catalogue is cross-screen state: the Routes screen selects a diff --git a/src/routes/row/+page.svelte b/src/routes/row/+page.svelte index ef717dbb..22413595 100644 --- a/src/routes/row/+page.svelte +++ b/src/routes/row/+page.svelte @@ -87,12 +87,22 @@
- {#if canvasBackend === 'unavailable'} - - {:else if session.sessionActive && ride.currentSession} + {#if session.sessionActive && ride.currentSession}
+ + {#if canvasBackend === 'unavailable'} +
+ +
+ {/if} + {/if}
+ +