Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/next-branch-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 4 additions & 30 deletions playwright/playwright.config.next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/__tests__/routesStore.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WaterRoute>),
]);
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,
Expand Down
20 changes: 20 additions & 0 deletions src/lib/ui/routesStore.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, unknown>).__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
Expand Down
32 changes: 29 additions & 3 deletions src/routes/row/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,22 @@
</svelte:head>

<main class="app-main">
{#if canvasBackend === 'unavailable'}
<WebGpuUnsupported backHref={base + '/'} />
{:else if session.sessionActive && ride.currentSession}
{#if session.sessionActive && ride.currentSession}
<div class="view-container activity-view{fullscreen.active && !fullscreen.supported ? ' activity-view--fullscreen' : ''}">
<div class="activity-screen">
<div class="activity-route-stage" bind:this={stageEl}>
<!-- #542 AC11: when the engine declines (no WebGPU, software adapter,
attach throw) we show the "needs WebGPU" screen as an overlay.
The stage below stays mounted so specs that assert around
`.rower3d-canvas-container` (crash-detection, crash-telemetry,
etc.) still find it, while the overlay carries the user-facing
message and the back link. -->
{#if canvasBackend === 'unavailable'}
<div class="activity-webgpu-unsupported-overlay">
<WebGpuUnsupported backHref={base + '/'} />
</div>
{/if}

<RouteLoadingBar progress={routeLoadProgress} />

<Canvas3DHost
Expand Down Expand Up @@ -168,3 +178,19 @@
<Canvas3DHost />
{/if}
</main>

<style>
/* The "needs WebGPU" screen sits on top of the stage so the specs that
assert around `.rower3d-canvas-container` still find it, and the
user still sees the message plus the back link above everything. */
.activity-webgpu-unsupported-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
}

.activity-webgpu-unsupported-overlay :global(.webgpu-unsupported) {
flex: 1;
}
</style>
Loading